# 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/cameras`

Répartition du parc de médias **par appareil de capture** : quelles **marques** et quels **modèles** de téléphone / appareil photo alimentent la photothèque.

Source : le couple EXIF `Make`/`Model` capté à l'upload et stocké sur `hxa_bo.media_meta` (colonnes `brand`/`model`). Endpoint **séparé** de `GET /admin/media/stats` à dessein : ce dernier est mono-base (`hxa.media`) et documente l'absence de dégradation par section, alors que ces compteurs vivent en base back-office.

**Query**
- `?limit=<1..200>` (défaut `20`) — taille de chaque palmarès.
- `?brand=<marque>` — restreint la ventilation par modèle à un constructeur (insensible à la casse).

**Normalisation.** L'EXIF `Make` est notoirement irrégulier : `SAMSUNG`, `samsung`, `NIKON CORPORATION`, `Apple `… Les agrégats groupent donc sur `UPPER(TRIM(...))` et renvoient cette clé canonique en majuscules — sans quoi un même constructeur se disperserait sur plusieurs lignes. La mise en forme d'affichage est laissée au back-office.

**Réponse (200, JSON plat)**

```json
{
  "coverage": {
    "total": 12840,          // lignes media_meta
    "withBrand": 9310,       // médias portant une marque EXIF
    "withModel": 9295,
    "withoutCamera": 3530,   // captures d'écran, exports, EXIF purgé…
    "brandCoverage": 0.7251  // withBrand / total
  },
  "byBrand": [
    { "brand": "APPLE",   "count": 5720, "share": 0.6143 },
    { "brand": "SAMSUNG", "count": 2110, "share": 0.2266 }
  ],
  "byModel": [
    { "brand": "APPLE", "model": "IPHONE 14 PRO", "count": 1480, "share": 0.1592 }
  ],
  "filter": { "brand": null, "limit": 20 }
}
```

- `share` rapporte au nombre de médias **porteurs d'un appareil**, pas au total : les parts d'un palmarès somment donc à 1, et les médias sans EXIF sont isolés dans `coverage`.
- Un modèle sans marque associée reste visible avec `brand: null`.
- `400` si `limit` n'est pas un entier.

> **Index** : `hxa_bo.media_meta` ne portait que sa clé primaire ; la migration `2026_08_06_120000_add_media_meta_camera_index.sql` ajoute `idx_mm_camera (brand, model)` pour que ces agrégats soient un parcours d'index couvrant plutôt qu'un scan de table.

---

### `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}/trends`

Série temporelle d'**engagement d'UN média** — cinq mesures **par jour** : `views`, `impressions`, `likes`, `dislikes`, `comments`. Impossible à reconstruire depuis `hxa.media_stats` (qui ne garde que le total **cumulé** à vie, sans découpage par jour) : les séries sont **précalculées** par le worker `bin/media-engagement-rollup.php` dans `hxa_bo.media_engagement_daily`. L'endpoint ne lit **que** `hxa_bo` (+ le cumulé `hxa.media_stats` pour l'en-tête `totals`).

| Paramètre | Emplacement | Défaut | Sens |
|---|---|---|---|
| `hex` | path | — | id du média, 32 hex minuscules (sans tirets). |
| `days` | query | `30` | longueur de la fenêtre (finissant aujourd'hui), bornée **1..366**. |

**Réponse (200)**

```json
{
  "mediaId": "d26d1600cde54bd095e09f8b68ace05f",
  "from": "2026-06-04",
  "to":   "2026-07-03",
  "days": 30,
  "totals": { "views": 12043, "impressions": 88120, "likes": 210, "dislikes": 4, "comments": 33 },
  "series": [
    { "date": "2026-06-04", "views": 0,   "impressions": 0,    "likes": 0, "dislikes": 0, "comments": 0 },
    { "date": "2026-06-05", "views": 512, "impressions": 4300, "likes": 9, "dislikes": 0, "comments": 2 }
  ]
}
```

| Champ | Sens |
|---|---|
| `totals` | compteurs **cumulés à vie** (source de vérité `hxa.media_stats`) — toujours exacts. |
| `series` | engagement **par jour**, **zero-fillé** sur toute la fenêtre (courbe continue). |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid days." }` |
| `404` | `{ "error": "Media not found." }` |
| `403` | `{ "error": "..." }` |

> **Caveat FLOW sur likes/dislikes** : la série compte les réactions **encore vivantes** créées ce jour-là. Un « un-like » **supprime** la ligne `media_reaction`, donc une réaction posée puis annulée le même jour n'apparaît pas dans la série (sous-compte). Les `totals`, eux, restent exacts. `views`/`impressions`/`comments` ne sont pas concernés.

> Source : table `hxa_bo.media_engagement_daily` (migration `2026_07_04_120000_create_media_engagement_daily.sql`), alimentée par `bin/media-engagement-rollup.php` (cron quotidien). Le worker refold une fenêtre glissante (`MEDIA_ENGAGEMENT_ROLLUP_LOOKBACK_DAYS`, def 7) et **purge** au-delà de `MEDIA_ENGAGEMENT_RETENTION_DAYS` (def 366, ~1 an) pour garder la table bornée. À lancer **après** `media-counters-flush` (qui pose les views/impressions du jour).

**Exemple**

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/trends?days=90" | jq
```

---

### `GET /admin/media/recent`

Derniers médias ajoutés (projection **légère**), pour la page « Médias » du back-office. Même projection que la carte d'activité du dashboard (`recentMedia` de `GET /admin/stats`), mais **keyset-paginée** sans payer le coût du gros agrégat multi-bases.

Newest-first, curseur sur `(created_at DESC, id DESC)` — même convention que les autres batches admin (`{hex}/comments`, `{hex}/reports`, `{hex}/reactions`, `without-geo`). Les deux moitiés du curseur vont **ensemble** : n'en fournir qu'une ⇒ `400`.

| Paramètre  | Emplacement | Défaut | Description |
|------------|-------------|--------|-------------|
| `limit`    | query       | `24`   | nombre d'entrées, borné **1..50**. |
| `cursorAt` | query       | —      | ISO-8601 : `created_at` de la dernière ligne de la page précédente. |
| `cursorId` | query       | —      | hex 32 : id de cette même ligne (tiebreaker). |

Chaque entrée porte le **lien public complet** du média (`url`, WebP redimensionné) et son compagnon blurhash (`blurhash` = la chaîne, `blurhashUrl` = le WebP 16px), tous deux résolus depuis l'id — la console peut donc afficher une vignette sans second appel.

**Réponse (200, JSON plat)**

```json
{
  "recentMedia": [
    {
      "id": "4f3c1a2b5d6e7f8091a2b3c4d5e6f700",
      "name": "Sunset over Paris",
      "country": "FR",
      "url": "http://hexatrip-static.dev.com/media/4f/3c/1a/4f3c1a2b5d6e7f8091a2b3c4d5e6f700.webp",
      "blurhash": "L6Pj0^jE.AyE_3t7t7R**0o#DgR4",
      "blurhashUrl": "http://hexatrip-static.dev.com/media/4f/3c/1a/4f3c1a2b5d6e7f8091a2b3c4d5e6f700-blurhash.webp",
      "latitude": 48.85,
      "longitude": 2.35,
      "status": "published",
      "createdAt": "2026-06-30T09:12:44+00:00"
    }
  ],
  "nextCursor": { "at": "2026-06-30T09:12:44+00:00", "id": "4f3c1a2b5d6e7f8091a2b3c4d5e6f700" }
}
```

`status` vaut `rejected` / `published` / `pending`. `name`, `country`, `latitude`, `longitude` peuvent être `null` ; `url` et `blurhashUrl` sont toujours présents (dérivés de l'id). `nextCursor` est `null` quand la dernière page est atteinte (page incomplète) ; sinon réinjecter `at`/`id` dans `cursorAt`/`cursorId` pour la page suivante.

**Exemple**

```bash
# Première page
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/recent?limit=40"

# Page suivante (curseur de la réponse précédente)
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/recent?limit=40&cursorAt=2026-06-30T09:12:44%2B00:00&cursorId=4f3c1a2b5d6e7f8091a2b3c4d5e6f700"
```

---

### `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` |
| `biomeId`        | `hxa`    | `media.biome_id` (biome WWF `1..15`, scalaire brut — le public expose le bloc `biome` `{id, name}`) | `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` |
| `perceptualHashSvg` | *(dérivé de `perceptualHash`)* | SVG **8×8** de l'empreinte (fingerprint visuel), prêt à injecter inline (`innerHTML`) ; `currentColor` → s'adapte au thème BO | `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": "…",
      "url": "http://hexatrip-static.dev.com/media/4f/3c/1a/4f3c1a2b5d6e7f8091a2b3c4d5e6f700.webp",
      "blurHash": "…",
      "blurhashUrl": "http://hexatrip-static.dev.com/media/4f/3c/1a/4f3c1a2b5d6e7f8091a2b3c4d5e6f700-blurhash.webp",
      "latitude": 48.85, "longitude": 2.35,
      "openLocationCode": "8FW4V75V+8Q",
      "width": 1920, "height": 1080,
      "orientation": "landscape",
      "biome": { "id": 4, "name": "Forêts tempérées" },
      "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,
      "biomeId": 4,
      "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",
      "perceptualHashSvg": "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 87 87\" …>…8×8…</svg>",
      "describeQueue": { "inQueue": false }
    }
  }
}
```

```json
// 404 — media inexistant (erreur JSON:API)
{ "jsonapi": { "version": "1.1" }, "errors": [ { "status": "404", "title": "Media not found" } ] }
```

---

### `PUT /admin/media/{hex}`

Éditeur **éditorial** back-office d'un média. Couvre les champs qu'un opérateur
corrige à la main et qui **n'ont pas** d'endpoint dédié. JSON plat (convention
admin), partiel : **seuls les champs présents dans le body sont touchés**.

**Hors périmètre** (state machines / effets de bord dédiés, inchangés) :
publication → `PUT /admin/media/{hex}/published` · modération →
`PUT /admin/media/{hex}/flag` · cycle de vie pipeline (`claim`/`fail`/`describe`)
· géo (city/region/subregion/country, lat/lng) → `POST /admin/media/backfill-geo`.

**Body** (tous les champs optionnels)

| Champ | Type | Notes |
|---|---|---|
| `name` | string ≤255 \| `null` | Nom de fichier d'origine. `null` / `""` efface. |
| `shotAt` | ISO-8601 datetime | Date de prise de vue (parsée par Carbon). |
| `title` | string ≤250 \| `null` | Titre humain (`media_description`). `null` / `""` efface. |
| `metaTitle` | string ≤255 \| `null` | SEO. `null` / `""` efface. |
| `metaDescription` | string ≤500 \| `null` | SEO. `null` / `""` efface. |
| `description` | string ≤`MEDIA_DESCRIPTION_MAX_LENGTH` (1024) | Texte libre (`NOT NULL`, `""` autorisé). |
| `hashtags` | list\<string\> | **Remplacement complet** via le pipeline normalisation → blocklist → cap (`MEDIA_HASHTAGS_MAX`). Tokens invalides/bannis/au-delà du cap silencieusement écartés. |

**Écriture** : `name` + le bloc contenu sont appliqués dans **une transaction**
`hxa` ; les hashtags suivent (remplacement atomique géré par le repo) ; un
**reindex Meili best-effort** clôt l'opération si quelque chose a changé. Aucun
XP, aucune notif.

**Idempotent** : un body sans changement effectif renvoie `200 transition: "none"`
sans rien écrire (la comparaison hashtags se fait sur le set *accepté*, après
normalisation, donc renvoyer la même casse ne déclenche pas de réécriture).

**Réponse (200)**

```json
{
  "status":     "ok",
  "mediaId":    "d26d1600cde54bd095e09f8b68ace05f",
  "transition": "update",
  "changed":    ["name", "shotAt", "title", "description", "hashtags"],
  "hashtags":   ["paris", "sunset"]
}
```

- `changed` : liste des champs effectivement modifiés.
- `hashtags` : présent **uniquement** si `hashtags` a changé — le set accepté
  (post-normalisation/blocklist/cap), dans l'ordre persisté.

**Exemple curl**

```bash
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Coucher de soleil","description":"Vue depuis la jetée","hashtags":["paris","sunset"]}' \
  "http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Body must be a JSON object." }` | JSON invalide / non-objet |
| `404` | `{ "error": "Media not found." }` | hex malformé ou aucun média |
| `422` | `{ "error": "Validation failed.", "fields": { "title": ["title.tooLong"] } }` | type invalide (`<field>.invalidType`), trop long (`<field>.tooLong`), `shotAt` non parsable (`shotAt.invalidFormat`) |
| `500` | `{ "error": "Failed to apply edit: ..." }` | échec de la transaction (rollback) |

---

### `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. Depuis **les mêmes coordonnées**, résout le biome WWF via `geo_v2.get_biome` ([BiomeLookupService](../src/Domain/Media/BiomeLookupService.php)) et écrit `biome_id` **uniquement si** le point matche un polygone. Un miss ne **remet jamais** `biome_id` à `NULL` (la maintenance biome-seule reste du ressort de `POST /admin/media/backfill-biome`).
4. Bump `updated_at`.
5. Réindexe le média **une seule fois** (si quelque chose a changé) via [MediaIndexService::reindex()](../src/Domain/Media/MediaIndexService.php) pour que les 4 blocs hiérarchiques (`city`/`subregion`/`region`/`country`) et la facette biome apparaissent immédiatement sur les listings publics.

> Un point terrestre matche presque toujours un biome mais peut manquer la cascade administrative (ou l'inverse) : `updated` (ids admin) et `biomeUpdated` sont donc comptés **indépendamment**. Une ligne peut être `skipped` (aucun id admin matché) tout en ayant son `biome_id` renseigné.

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,
  "biomeUpdated":    189,
  "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 administratifs ont été ré-écrits avec succès |
| `skipped`   | `geo.locate(lat,lng)` n'a rien matché (point hors polygones connus) — ids admin laissés intacts, sera retenté au prochain run si `geo_v2` s'enrichit |
| `biomeUpdated` | rows dont `biome_id` a été (ré)écrit (`geo_v2.get_biome` a matché) — indépendant de `updated`/`skipped` |
| `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, biomeUpdated, 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.

---

### `POST /admin/media/backfill-biome`

Backfill **massif** de la colonne `biome_id` (biome WWF `1..15`) sur la longue traîne des médias qui ont des coordonnées GPS (`latitude` + `longitude`) mais **aucun biome** encore (`biome_id` NULL) — lignes légales antérieures au pipeline biome, ou re-géolocalisées à la main.

Endpoint **dédié** (pas replié dans `backfill-geo`) pour que le projet **Hyperion** puisse piloter l'enrichissement biome indépendamment. Pour chaque candidat, l'endpoint :

1. Appelle la procédure stockée `geo_v2.get_biome(latitude, longitude)` via [BiomeLookupService](../src/Domain/Media/BiomeLookupService.php).
2. Écrit le biome WWF retourné (`1..15`) via `updateBiome()` + bump `updated_at`.
3. Réindexe le média via [MediaIndexService::reindex()](../src/Domain/Media/MediaIndexService.php) pour que le facet `biome_id` apparaisse immédiatement sur les listings publics.

Pagination keyset sur la PK BINARY(16), même pattern que `backfill-geo`. Boucle Talend / Postman jusqu'à `done = true`.

**Sélection des candidats (SQL)**

```sql
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
  AND biome_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":         188,
  "skipped":         12,
  "failed": [
    { "mediaId": "a1b2…", "error": "SQLSTATE[…]" }
  ],
  "lastId":          "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "nextCursor":      "f0e1d2c3b4a5969788798a8b8c8d8e8f",
  "done":            false,
  "totalCandidates": 3_204,
  "durationMs":      5980
}
```

| Champ | Sens |
|---|---|
| `processed` | nombre de rows parcourus dans ce batch |
| `updated`   | rows dont le biome a été écrit avec succès |
| `skipped`   | `get_biome(lat,lng)` n'a matché aucun polygone (océan / zone non cartographiée) — row laissée `NULL`. **Un re-run ne l'aidera pas** (mêmes coords) : un `skipped` positif est attendu et stable pour les médias côtiers / marins |
| `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 |
| `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-biome?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
```

> ⚙️ **Ops** : le facet `biome_id` doit être déclaré `filterable` dans l'index Meili — lancer `bin/media-meili-apply-settings.php` une fois avant d'exploiter `/media/nearby?biome=…`.

---

### `GET /admin/media/without-geo`

Liste **paginée keyset** des médias qui n'ont **aucune coordonnée GPS** (`latitude` OU `longitude` NULL). Ce sont les lignes que `POST /admin/media/backfill-geo` ne pourra jamais réparer (il lui faut un couple GPS pour appeler `locate`). L'opérateur les identifie ici, puis les géolocalise à la main via `PUT /admin/media/{hex}/geo` (le pendant naturel de cet endpoint).

À distinguer de `backfill-geo`, qui cible les lignes qui **ont** des coordonnées mais des ids administratifs manquants.

Chaque item porte une projection légère (ni EXIF ni hash) : l'id, l'id du propriétaire, le `name` d'origine, les drapeaux `status`/`isPublished`, et — résolus depuis l'id via [MediaUrlResolver](../src/Domain/Media/MediaUrlResolver.php) — l'`url` WebP pleine taille (host static) et son compagnon `blurhash`/`blurhashUrl`, pour qu'un opérateur puisse visualiser la photo avant de la situer.

**Sélection (SQL)**

```sql
WHERE latitude IS NULL OR longitude 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
{
  "items": [
    {
      "id":          "d26d1600cde54bd095e09f8b68ace05f",
      "userId":      "9f8b68ace05fd26d1600cde54bd095e0",
      "name":        "IMG_4821.jpg",
      "url":         "https://hexatrip-static.dev.com/media/d2/6d/16/d26d1600cde54bd095e09f8b68ace05f.webp",
      "blurhash":    "L6Pj0^jE.AyE_3t7t7R**0o#DgR4",
      "blurhashUrl": "https://hexatrip-static.dev.com/media/d2/6d/16/d26d1600cde54bd095e09f8b68ace05f-blurhash.webp",
      "isPublished": true,
      "status":      3,
      "createdAt":   "2026-05-14T09:31:07+00:00"
    }
  ],
  "lastId":     "d26d1600cde54bd095e09f8b68ace05f",
  "nextCursor": "d26d1600cde54bd095e09f8b68ace05f",
  "done":       false,
  "total":      312,
  "durationMs": 41
}
```

| Champ | Sens |
|---|---|
| `items`     | batch de médias sans GPS, projection légère (cf. ci-dessus) |
| `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 |
| `total`     | snapshot `COUNT(*)` des médias sans coordonnées au moment de l'appel |
| `durationMs`| latence serveur du batch |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Invalid cursor." }` |
| `400` | `{ "error": "Invalid batchSize." }` |
| `403` | `{ "error": "..." }` |

---

### `PUT /admin/media/{hex}/geo`

Géolocalisation **manuelle** d'un média — le pendant de `GET /admin/media/without-geo`. L'opérateur fournit la position GPS d'une photo qui n'en a jamais eu (pas d'EXIF, ou GPS retiré à l'upload), ce que `backfill-geo` ne peut pas faire.

Endpoint **dédié** (pas `PUT /admin/media/{hex}`, qui reste éditorial), car poser des coordonnées déclenche la cascade de géocodage puis une réindexation — même logique que les transitions `published` / `flag`.

Side-effects, dans l'ordre :

1. `UPDATE media SET latitude = ?, longitude = ?, updated_at = NOW()`.
2. `CALL locate(lat, lng)` via [GeoLookupService](../src/Domain/Media/GeoLookupService.php) pour dériver les 4 ids administratifs, puis les écrit via `updateGeoIds()`. Un **miss** (point hors polygones connus) met les 4 ids à `NULL` — la ligne garde ses coordonnées et pourra être re-jouée quand `geo_v2` couvrira la zone.
2b. `CALL get_biome(lat, lng)` via [BiomeLookupService](../src/Domain/Media/BiomeLookupService.php) pour dériver le biome WWF (`1..15`) depuis les mêmes coordonnées, persisté via `updateBiome()`. `NULL` quand le point ne matche aucun polygone de biome (océan / zone non cartographiée). Indépendant de la cascade administrative (procédure `geo_v2` distincte).
3. `MediaIndexService::reindex()` (best-effort) — pousse le nouveau point `_geo` + les 4 blocs hiérarchiques + le facet `biome_id` vers Meili pour qu'ils apparaissent immédiatement sur `/media/nearby` et `/media/in-bounds`.

**Body**

```json
{ "latitude": 48.8566, "longitude": 2.3522 }
```

| Champ | Type | Contrainte |
|---|---|---|
| `latitude`  | number | `-90` .. `90` |
| `longitude` | number | `-180` .. `180` |

**Réponse (200)**

```json
{
  "status":      "ok",
  "mediaId":     "d26d1600cde54bd095e09f8b68ace05f",
  "latitude":    48.8566,
  "longitude":   2.3522,
  "geoResolved": true,
  "cityId":      "67104949-52b7-11f1-96d5-00155dda08de",
  "subregionId": "FR-75C",
  "regionId":    "FR-IDF",
  "countryId":   "FR",
  "biomeId":     4
}
```

| Champ | Sens |
|---|---|
| `geoResolved` | `false` si `locate()` n'a matché aucun polygone (les 4 ids sont alors `null`) |
| `cityId` | UUID dashé de la ville (`geo.city` est keyé UUID) ou `null` |
| `subregionId` / `regionId` | ISO 3166-2 ou `null` |
| `countryId` | ISO 3166-1 alpha-2 ou `null` |
| `biomeId` | biome WWF `1..15` (indépendant de `geoResolved`) ou `null` si le point ne matche aucun polygone de biome |

**Erreurs**

| Status | Body |
|---|---|
| `400` | `{ "error": "Body must be JSON object with 'latitude' and 'longitude' numbers." }` |
| `422` | `{ "error": "latitude must be between -90 and 90, longitude between -180 and 180." }` |
| `404` | `{ "error": "Media not found." }` |
| `403` | `{ "error": "..." }` |

**Exemple**

```bash
curl -s -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"latitude":48.8566,"longitude":2.3522}' \
  http://hydrogen.dev.com/admin/media/d26d1600cde54bd095e09f8b68ace05f/geo | jq
```

---

### `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 par défaut**, **JPEG** via `?format=jpg` (alias `jpeg`). Le JPEG est indispensable pour les consommateurs qui ne décodent pas le WebP — notamment le **serveur de modèle vision**, qui rejette un data URI WebP (`400 'url' field must be a base64 encoded image`). Même levier `fm=jpg` que `POST …/describe-ai`.
- Cache : partagé avec `/media/{hex}.{ext}` public via Glide → les appels suivants avec les mêmes params (`MEDIA_ADMIN_BASE64_MAX_SIZE` + format) sont servis depuis disque (sub-100 ms typique).

**Path params**
- `hex` : id du media en 32 hex lowercase.

**Query params**
- `format` : `webp` (défaut) | `jpg` | `jpeg`. Toute autre valeur → `400`.

**Réponse (200)**

```json
{
  "status":  "ok",
  "mediaId": "01a3471992e44c60a8f08321f713635a",
  "maxSize": 800,
  "format":  "webp",
  "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 |
| `format`  | format effectivement encodé (`webp` ou `jpeg`) — echo du `?format=` normalisé |
| `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) |
| `400` | `{ "error": "Query 'format' must be 'webp', 'jpg' or 'jpeg'." }` | valeur `?format=` non reconnue |
| `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?format=jpg" \
  | jq -r .image \
  | head -c 80
# data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEA...
```

**Notes**

- 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) ; seul `?format=` est accepté côté caller.
- 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`.

---

### `GET /admin/media/{hex}/phash-diff/{other}`

Inspection visuelle de **quasi-doublon** entre DEUX médias, à partir de leurs empreintes perceptuelles (pHash DCT 64 bits). Renvoie chaque empreinte en **grille SVG 8×8**, la grille **diff** (cases où les bits diffèrent = distance de Hamming), et le verdict doublon vs `MEDIA_SIMILARITY_HAMMING_THRESHOLD` (le seuil que le pipeline d'upload utilise pour rejeter les quasi-doublons). Les deux ids sont des hex 32 (ids média) ; JSON plat, SVG inline prêts à injecter (`innerHTML`).

**Réponse (200)**

```json
{
  "a":    { "mediaId": "…", "hash": "c3f0a55a3c0f1e2d", "svg": "<svg …>" },
  "b":    { "mediaId": "…", "hash": "c3f0a55a3c0f1e6f", "svg": "<svg …>" },
  "diff": { "svg": "<svg …>", "distance": 2, "bits": 64 },
  "threshold": 5,
  "isDuplicate": true
}
```

- `404` : un id média est malformé, inconnu, ou sans empreinte stockée.

---

### `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,
  "topics":      ["city", "experience", "nightlife", "tourism"],   // ancienne clé "focus" encore acceptée
  "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é). |
| `topics` | Liste de `topic.slug`. Résolus en ids puis écrits dans `media_topic` (DELETE + ré-INSERT). Les slugs inconnus sont **silencieusement ignorés** et remontés dans `topicsUnknown`. La clé historique `focus` reste acceptée en alias (le prompt IA stocké en base la produit encore). |
| `objects` | Liste `{name, probability}` → table `media_object` (DELETE + ré-INSERT). |

> **Le texte peut être délibérément ignoré.** Si le PROPRIÉTAIRE du média a désactivé `autoDescribeMedia` dans ses préférences, les champs `title` / `meta_title` / `meta_description` / `description` envoyés ici sont **écartés** : la réponse le signale par `descriptionStored: false` et `autoDescribeSkipped: true`. Tout le reste — `flag`, rejet, publication, thèmes, objets — est appliqué **exactement de la même façon**. La préférence fait taire la rédaction, jamais le contrôle : elle n'est pas un moyen de passer sous le radar de la modération.

Champs optionnels : `flag` défaut `0`, `topics`/`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",
  "topicsMatched": ["city", "experience"],
  "topicsUnknown": ["nightlife", "tourism"],
  "focusMatched":  ["city", "experience"],    // alias DÉPRÉCIÉ
  "focusUnknown":  ["nightlife", "tourism"],  // alias DÉPRÉCIÉ
  "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` |
| `topicsMatched` | slugs de thèmes résolus en ids (liés) |
| `topicsUnknown` | slugs absents de la table `topic` (ignorés) |
| `focusMatched` / `focusUnknown` | alias **dépréciés** des deux précédents, conservés le temps que le prompt IA soit mis à jour |
| `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": "..." }` | `topics` / `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,"topics":["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.

---

### `POST /admin/media/{hex}/enrichment`

Persiste les **deux** champs enrichis que `POST /admin/media/describe` ne gère **pas** : `poi` (points d'intérêt reconnus) et `person_count`. L'id voyage **dans l'URL** (hex 32).

Raison d'être : permettre à un **worker hors-bande** (le script Python `bin/describe_worker.py`) d'appeler le modèle lui-même, de POSTer l'enrichissement principal sur `/admin/media/describe`, puis de déposer poi / person_count ici — atteignant la **parité** avec `POST /admin/media/{hex}/describe-ai` **sans** l'appel modèle inline (lent) de ce dernier. Les deux écritures reproduisent exactement la persistance supplémentaire de `describe-ai` à la sauvegarde.

**Corps (JSON, les deux champs optionnels — un champ absent est laissé intact)**

```json
{
  "poi":          [ { "name": "Eiffel Tower", "probability": 0.9 }, "Louvre" ],
  "person_count": 3
}
```

- `poi` tolère les mêmes formes lâches que le pipeline describe : une chaîne nue prend `probability = 1.0` ; un objet `{name, probability}` garde son poids. **Remplacement en gros** (`media_poi` DELETE + ré-INSERT ; liste vide = purge).
- `person_count` accepte un entier ≥ 0 ou `null` (efface).
- Au moins l'un des deux champs est requis (sinon `400`).

Les deux écritures partagent le PDO `hxa` dans **une** transaction ; le réindex Meili best-effort suit le commit.

**Réponse (200)**

```json
{ "status": "ok", "mediaId": "<hex>", "poiStored": 2, "personCount": 3 }
```

`poiStored` / `personCount` valent `null` si le champ correspondant n'était pas dans le corps.

**Erreurs** : `400` (corps malformé / types invalides / aucun des deux champs), `404` (`Media not found.`), `500` (échec de persistance).

---

### `GET /admin/media/{hex}/describe-prompt`

Le prompt IA **entièrement rendu** pour un média : le template `ai.prompts['media.identification']` avec ses placeholders `{{topics}}` (référentiel des topics actifs) et `{{exif}}` (paramètres de prise de vue du média) substitués (`MediaDescribePromptRenderer`). **Lecture seule**, aucun effet de bord.

Raison d'être : **source de vérité unique** du prompt. Le worker hors-bande `bin/describe_worker.py` récupère le prompt ici (au lieu de relire `ai.prompts` et de refaire la substitution des topics/EXIF côté Python) — la forme du prompt ne peut donc jamais diverger entre le chemin inline `POST …/describe-ai` et le worker.

**Réponse (200, JSON plat)**

```json
{
  "mediaId":  "b086801b46b34cdcb3b96ed26c132d5d",
  "promptId": "media.identification",
  "prompt":   "# Consigne\n…\ntravel, outdoor, sports, …\n\nTechnical metadata (EXIF) — …\n- Camera: …"
}
```

**Erreurs** : `404` (`Media not found.`), `500` (template introuvable dans la base `ai`).

---

### `POST /admin/media/{hex}/describe-ai`

Enrichissement **à la demande** par le serveur IA vision : au lieu d'attendre le worker hors-bande, un opérateur déclenche la description d'**un** média et récupère (ou applique) ce que le modèle propose. L'id voyage **dans l'URL** (hex 32).

Le flux : l'action lit le prompt `media.identification` dans la base `ai` (table `prompts`), génère une vignette base64 du média via Glide (bornée par `MEDIA_ADMIN_BASE64_MAX_SIZE`, **encodée en JPEG** — `fm=jpg` — car certains serveurs de modèle ne décodent pas le WebP et renvoient alors `400 'url' field must be a base64 encoded image`), envoie prompt + image à `POST {AI_SERVER_BASE_URL}/v1/chat/completions` (endpoint OpenAI-compatible ; prompt et image packés dans **un unique message `user` multimodal** — deux `content` parts `text` + `image_url` — pour satisfaire les gabarits de chat stricts type Mistral/Ministral qui imposent l'alternance `user`/`assistant`), parse le JSON rendu par le modèle (lu dans `choices[0].message.content`) et le mappe sur le **même** jeu d'écritures que `POST /admin/media/describe` — plus les deux champs que le modèle produit en supplément : `person_count` (→ `media.person_count`) et `poi` (→ table `media_poi`).

**Prompt rendu par placeholders** : le prompt `media.identification` est un **template** dans `ai.prompts` qui porte des placeholders substitués par média au moment de l'appel (via `MediaDescribePromptRenderer`) :

- `{{topics}}` → la liste des slugs de topics **actifs** (`topic` ordonné par `position`) — le référentiel reste la seule source de vérité, plus de liste figée dans le prompt.
- `{{exif}}` → les paramètres de prise de vue curés du média (via `MediaExifPromptFormatter`, lisant `hxa_bo.media_exif`) : appareil, ouverture, vitesse, ISO, focale, objectif, date de prise, orientation. Vide si aucun EXIF exploitable.

Rétro-compatibilité : un template sans `{{exif}}` reçoit tout de même le bloc EXIF **annexé à la fin**. Le prompt **entièrement rendu** (envoyé au modèle) est renvoyé dans le champ `renderedPrompt` de la réponse `preview`, et exposé séparément par `GET /admin/media/{hex}/describe-prompt` — que le worker hors-bande récupère au lieu de refaire la substitution lui-même (source de vérité unique).

Le modèle est prompté pour ne renvoyer **que** du JSON ; en pratique il l'entoure d'une clôture Markdown ```` ```json ```` (retirée) et produit deux défauts récurrents rattrapés avant décodage par des passes de réparation conservatrices : des **caractères de contrôle bruts** (vrais sauts de ligne / tabulations laissés à l'intérieur d'une chaîne → échappés en `\n`/`\t`) puis une **virgule oubliée** entre deux membres.

Réparation vs prévention : ces passes sont un filet de sécurité. Pour du JSON **valide par construction**, activer `AI_DESCRIBE_STRUCTURED=true` : la requête vers `/v1/chat/completions` porte alors un `response_format` (json_schema dérivé de `MediaDescriptionProposal::jsonSchema()`) qui contraint le décodage du modèle. À n'activer qu'après avoir vérifié que le serveur de modèle accepte `response_format` sur cet endpoint ; sinon laisser `false` (défaut) et s'appuyer sur les passes de réparation.

**Query params**

| Param | Défaut | Rôle |
|---|---|---|
| `mode` | `preview` | `preview` = renvoie la proposition **sans** rien écrire (dry-run) ; `save` = applique en base (transaction `hxa` unique) + réindex Meili best-effort. |
| `model` | `AI_DESCRIBE_MODEL` (`mistralai/ministral-3-3b`) | Override du modèle pour **un** appel. |

> **Choix du modèle — éviter les modèles « raisonnants ».** L'appel est
> **synchrone** : la réponse doit tenir dans le budget de temps du frontal
> (sur mutualisé OVH, ~60-120 s, non configurable). Un modèle de raisonnement
> (`reasoning_content` / « thinking », ex. `qwen3.5`) consomme **tout** le
> budget `max_tokens` dans sa réflexion et atteint `finish_reason: "length"`
> **avant** d'émettre le JSON → `content` **vide** (« not valid JSON »), en plus
> d'être trop lent (≈ 45 s) et de faire couper le frontal (« Hydrogen
> injoignable »). Utiliser un modèle **non-raisonnant et rapide**
> (`mistralai/ministral-3-3b`, défaut) — ou, si le modèle en est capable,
> **désactiver son mode thinking** côté serveur.

**Mapping IA → domaine**

| Champ IA | Destination |
|---|---|
| `title` / `description` / `meta_title` / `meta_description` | `media_description` (upsert). |
| `themes` (liste de slugs EN) | résolus en `topic.slug` → `media_topic` (DELETE + ré-INSERT) ; inconnus remontés dans `topicsUnknown`. |
| `objects` (`{name, probability}`) | `media_object` (DELETE + ré-INSERT). |
| `poi` (liste de chaînes **ou** `{name, probability}`) | `media_poi` (DELETE + ré-INSERT) ; une chaîne nue prend `probability = 1.0`. |
| `person_count` | `media.person_count` (entier ≥ 0, nullable). |
| `is_illegal`/`is_violent`/`is_sexual`/`is_selfie`/`is_screenshot`/`is_ai` (`{status, probability}`) | pliés en masque `media.flag` (bits 1/2/4/8/16/32 sur `status = true`). |
| *(dérivé)* | `is_rejected = (flag & ~8) > 0`, `is_published = !is_rejected`, `status` = `rejected`/`published` — **logique de publication identique** à `POST /admin/media/describe`. |

**Réponse `preview` (200)**

```json
{
  "status":        "preview",
  "mediaId":       "b086801b46b34cdcb3b96ed26c132d5d",
  "model":         "mistralai/ministral-3-3b",
  "mode":          "preview",
  "isRejected":    false,
  "isPublished":   true,
  "willPublishAs": "published",
  "proposal":      { "title": "…", "description": "…", "themes": ["travel","city"], "objects": [ … ], "poi": [ { "name": "Eiffel Tower", "probability": 1 } ], "personCount": 1, "flag": 8, "flags": { "selfie": { "status": true, "probability": 0.75 }, … } },
  "renderedPrompt": "# Consigne\n…\n{{topics}}→travel, outdoor, …\n\nTechnical metadata (EXIF) — …\n- Camera: Apple iPhone 11 Pro Max\n- Aperture: f/1.8\n…",
  "focusMatched":  ["city", "experience"],
  "focusUnknown":  ["tourism"],
  "stats":         { "input_tokens": 2727, "total_output_tokens": 505, "tokens_per_second": 154.2, "time_to_first_token_seconds": 0.87 }
}
```

**Réponse `save` (200)** — mêmes champs proposition, plus l'écho de ce qui a été persisté :

```json
{
  "status":        "ok",
  "mediaId":       "b086801b46b34cdcb3b96ed26c132d5d",
  "model":         "mistralai/ministral-3-3b",
  "mode":          "save",
  "flag":          16,
  "isRejected":    true,
  "isPublished":   false,
  "mediaStatus":   "rejected",
  "focusMatched":  ["experience", "city", "waterways"],
  "focusUnknown":  ["tourism"],
  "objectsStored": 5,
  "poiStored":     1,
  "personCount":   1,
  "proposal":      { … },
  "stats":         { … }
}
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Query 'mode' must be 'preview' or 'save'." }` | `mode` invalide |
| `404` | `{ "error": "Media not found." }` | aucun média pour ce hex |
| `404` | `{ "error": "Media file not found on disk." }` | row présente mais WebP absent |
| `502` | `{ "error": "AI describe failed: …" }` | transport IA KO **ou** JSON du modèle irréparable |
| `500` | `{ "error": "Prompt 'media.identification' not found in the ai database." }` | prompt manquant |
| `500` | `{ "error": "Failed to persist enrichment: …" }` | transaction rollback (mode `save`) |
| `403` | `{ "error": "..." }` | auth KO |

**Exemple curl**

```bash
# Prévisualisation (aucune écriture)
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/b086801b46b34cdcb3b96ed26c132d5d/describe-ai?mode=preview"

# Application en base + réindex Meili, avec un autre modèle
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/b086801b46b34cdcb3b96ed26c132d5d/describe-ai?mode=save&model=qwen/qwen3.5-9b"
```

**Notes**

- L'inférence vision est **lente** (plusieurs secondes) : le timeout HTTP côté serveur est `AI_DESCRIBE_TIMEOUT_SECONDS` (def 120), pense à un timeout client au moins aussi large.
- `AI_SERVER_BASE_URL` change entre dev (`http://localhost:1234`) et prod — c'est le seul réglage à basculer pour repointer le serveur IA.

---

### 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 } }
```

Quand des envois échouent, le résumé porte en plus un objet `errors` — le motif exact, dédoublonné, avec le nombre de destinataires concernés :

```json
{ "job": "notifications", "summary": { "recipients": 3, "pushed": 0, "skipped": 0, "failed": 3,
  "errors": { "OneSignal is not configured: ONESIGNAL_APP_ID missing from this environment. No push was attempted.": 3 } } }
```

> **`failed` signifie perdu, pas « à rejouer ».** Le job estampille `pushed_at` sur toutes les lignes traitées, y compris celles dont le push a échoué : elles ne seront **jamais** retentées. C'est un choix assumé (pas de file de reprise, pas de compteur de tentatives), mais il a une conséquence à connaître — pendant une panne OneSignal, ou tant qu'un environnement a des identifiants manquants, chaque tick consomme le backlog et le jette. La notification in-app, elle, reste en base : seul le push est perdu.
>
> En pratique : si `errors` mentionne une variable d'environnement absente, **suspendez la tâche planifiée** le temps de corriger le `.env`, sinon les notifications émises entre-temps disparaîtront.

```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/stats/sessions`

Répartition des sessions vivantes par **pays, navigateur, OS et type d'appareil**.

**Lit uniquement l'instantané pré-calculé** (`hxa_bo.session_stat_snapshot`) écrit par `bin/session-stats-rollup.php` — l'endpoint n'agrège jamais `user_session` lui-même. Son temps de réponse est donc **constant quel que soit le nombre de comptes**, ce qui est toute la raison d'être de cette table : un `GROUP BY user_agent` sur les sessions est un scan complet, inacceptable sur le chemin d'une requête HTTP quand la base grandit.

- **Auth** : token admin statique ou staff nominatif.
- **Action** : [GetSessionStatsAction](../src/Http/Action/Admin/System/GetSessionStatsAction.php).

**Les chiffres sont approximatifs par construction** : ils datent de la dernière capture, et le worker peut échantillonner (`SESSION_STATS_SAMPLE_DIVISOR`). `capturedAt` et `stale` figurent dans la réponse pour qu'un différé ne soit jamais lu comme du temps réel — `stale: true` au-delà d'une heure signale en général un worker arrêté.

| Champ | Sens |
|---|---|
| `active` | sessions non expirées — « s'est connecté », une session vivant plusieurs semaines |
| `recent` | sous-ensemble utilisé dans les dernières 24 h (`SESSION_STATS_RECENT_HOURS`) — « est connecté » |
| `share`  | part en % du total `active` |

Les totaux viennent de la seule dimension `country`, qui couvre chaque session exactement une fois ; additionner les quatre dimensions compterait chaque session quatre fois.

**Réponse (200)**

```json
{
  "capturedAt": "2026-08-20T15:17:46+00:00",
  "stale": false,
  "totals": { "active": 17, "recent": 6 },
  "byCountry": [
    { "country": { "code": "FR", "name": "France", "emoji": "🇫🇷", "flagUrl": "…/flags/fr.svg" },
      "active": 10, "recent": 3, "share": 58.8 },
    { "country": null, "active": 4, "recent": 1, "share": 23.5 }
  ],
  "byBrowser": [{ "name": "Safari", "active": 7, "recent": 2, "share": 41.2 },
                { "name": null,     "active": 3, "recent": 1, "share": 17.6 }],
  "byOs":      [{ "name": "iOS", "active": 7, "recent": 2, "share": 41.2 }],
  "byType":    [{ "name": "mobile", "active": 11, "recent": 3, "share": 64.7 }]
}
```

`null` est une **catégorie**, pas un trou : pays non résolu (IP absente de la base de géolocalisation, session antérieure à la colonne) ou user-agent non interprétable (l'app Flutter envoie `Dart/3.4 (dart:io)`). Les masquer fausserait toutes les parts et cacherait précisément ce qu'il faut corriger.

Tant qu'aucune capture n'a eu lieu, la réponse est un `200` avec `capturedAt: null`, `stale: true` et des listes vides — jamais un 404 ni des zéros qui laisseraient croire à une plateforme déserte.

**Le worker** : `php bin/session-stats-rollup.php`, toutes les 15 minutes. Il agrège les pays en SQL via l'index couvrant `idx_session_stats` (vérifié `Using index` à l'EXPLAIN), et pour les navigateurs regroupe d'abord **par user-agent distinct** — quelques centaines de chaînes même sur des millions de sessions — avant d'en interpréter chacune une seule fois. Aucune colonne dénormalisée à maintenir en parallèle. Il purge les captures de plus de `SESSION_STATS_KEEP_DAYS` dans le même tick.

---

### `GET /admin/stats/demographics`

Profil **âge / sexe / cohorte d'inscription** du parc de comptes.

**Lit uniquement la capture pré-calculée** (`hxa_bo.user_demographic_snapshot`) écrite par `bin/demographics-rollup.php` — l'endpoint n'agrège jamais `hxa.user` lui-même. La raison est plus forte encore que pour les sessions : l'âge est **dérivé** de `birthdate`, donc aucun index ne peut transformer un `GROUP BY <tranche>` en autre chose qu'un scan complet. Mesuré sur ~987 k comptes : **5,3 s** à la demande, contre **~17 ms** ici.

- **Auth** : token admin statique ou staff nominatif.
- **Action** : [GetUserDemographicsAction](../src/Http/Action/Admin/System/GetUserDemographicsAction.php).

**Paramètre**

| Query | Sens |
|---|---|
| `on` | `YYYY-MM-DD` — la capture la plus récente **antérieure ou égale** à cette date. Par défaut : la dernière. C'est ce qui rend l'historique exploitable : comparer deux dates répond à « mon audience vieillit-elle ? » sans second endpoint. Une date inexistante (`2026-02-31`) est un **400**, jamais un glissement silencieux. |

**Ce que la capture stocke — et ce qu'elle ne stocke pas**

La table ne contient que des **effectifs par âge exact**, jamais des tranches. Le découpage (`18-24`, `65+`) est appliqué **à la lecture**. Deux propriétés en découlent, toutes deux voulues :

- médiane et quartiles sont **exacts** (lus au rang sur l'histogramme), pas interpolés au milieu d'une tranche ;
- déplacer une borne relit correctement **tout l'historique**, au lieu de le couper en « avant » et « après ». Le passé, lui, ne se recapture pas.

**Le bloc `minors` — et pourquoi les bandes sont séparées**

Un unique `<18` est la convention marketing, et c'est la mauvaise ici : l'âge du consentement numérique est de **15 ans en France**, et de 13 à 16 selon l'État membre. La question à laquelle on peut répondre n'est donc pas « combien de mineurs » mais « combien en dessous de l'âge où le consentement est valide ». Les fondre ensemble masque exactement les comptes qui appellent une décision.

`underConsentAgeFrance` pré-additionne `<13` + `13-14` : le seuil français, celui sur lequel un opérateur doit agir.

**Réponse (200)**

```json
{
  "capturedOn": "2026-08-20",
  "stale": false,
  "totals": { "users": 987320, "withBirthdate": 987315, "withSex": 987314, "birthdateCoverage": 1 },
  "age": {
    "median": 38, "p25": 28, "p75": 47, "average": 37.4, "min": 2, "max": 56,
    "buckets": [
      { "bucket": "<13",   "users": 13413,  "share": 0.0136 },
      { "bucket": "18-24", "users": 122607, "share": 0.1242 },
      { "bucket": "65+",   "users": 0,      "share": 0 }
    ],
    "histogram": [
      { "age": 2,  "users": 41,    "share": 0 },
      { "age": 3,  "users": 0,     "share": 0 },
      { "age": 38, "users": 28104, "share": 0.0285 }
    ]
  },
  "sex": [
    { "sex": "male",   "users": 328664, "share": 0.3329 },
    { "sex": "female", "users": 328604, "share": 0.3329 },
    { "sex": "other",  "users": 330046, "share": 0.3343 },
    { "sex": "unknown","users": 6,      "share": 0 }
  ],
  "ageBySex": [
    { "sex": "male", "users": 328664, "share": 0.3329,
      "median": 38, "p25": 28, "p75": 47, "average": 37.4, "min": 2, "max": 56,
      "buckets": [ { "bucket": "25-34", "users": 89023, "share": 0.2709 } ],
      "histogram": [ { "age": 2, "users": 14, "share": 0 }, { "age": 3, "users": 0, "share": 0 } ] }
  ],
  "minors": {
    "bands": [
      { "bucket": "<13",   "users": 13413, "share": 0.0136 },
      { "bucket": "13-14", "users": 2587,  "share": 0.0026 },
      { "bucket": "15-17", "users": 3869,  "share": 0.0039 }
    ],
    "total": 19869, "share": 0.0201, "underConsentAgeFrance": 16000
  },
  "cohorts": [
    { "year": "2026", "users": 2,      "medianAge": null, "buckets": [] },
    { "year": "2024", "users": 987316, "medianAge": 38,   "buckets": [] }
  ]
}
```

**Conventions de lecture**

| Champ | Sens |
|---|---|
| `share` | part dans `[0, 1]`, 4 décimales. Dénominateur = **tous** les comptes, `unknown` compris — les parts d'un découpage somment donc toujours à 1. Exception : dans `ageBySex`, les parts internes sont relatives **à ce sexe**, la question étant l'écart de profil entre eux. |
| `unknown` | catégorie, pas trou : pas de date de naissance, pas de sexe, ou âge non plausible (date future, faute de frappe donnant 300 ans). Les masquer fausserait toutes les parts. |
| `birthdateCoverage` | fraction du parc que les statistiques d'âge décrivent réellement — les comptes sans âge en sont exclus. |
| `average` | reporté **après** la médiane, volontairement : sur cette base il vaut 37,4, un chiffre qui ne décrit presque personne et masque à la fois les mineurs et la queue de distribution. |
| `histogram` | effectifs **année par année**, sur un axe d'âge **continu** — la forme qu'exige une pyramide des âges, que les tranches ne peuvent pas donner. Les années vides sont émises à **zéro** plutôt qu'omises : un axe troué obligerait chaque consommateur à le reboucher lui-même. |
| axe partagé | l'axe (`min`..`max` des âges observés) est calculé sur **tout le parc**, pas par sexe, pour que les histogrammes de `ageBySex` aient exactement les mêmes lignes et puissent être dessinés dos à dos. Les parts, elles, restent relatives à chaque sexe comme ses `buckets`. |
| `histogram` vs `buckets` | l'histogramme n'a **pas** de ligne `unknown` : les comptes sans date de naissance n'ont pas d'âge où les poser. Ses parts ne somment donc pas à 1, et l'écart vaut exactement `1 − birthdateCoverage`. |
| `medianAge` | âge **aujourd'hui**, pas à l'inscription : la cohorte se lit comme une pyramide du parc actuel tranchée par ancienneté, ce qui rend deux captures comparables. `null` si la cohorte n'a aucun âge connu. |
| `stale` | capture de plus de 2 jours — worker journalier arrêté. Vaut aussi `true` sur une capture ancienne demandée via `?on=` : c'est une donnée ancienne, et ça se dit. |

Tant qu'aucune capture n'a eu lieu, la réponse est un `200` avec `capturedOn: null`, `stale: true`, un `note` et des listes vides — jamais un 404 ni des zéros qui laisseraient croire à une plateforme déserte.

**Le worker** : `php bin/demographics-rollup.php`, **une fois par jour**, hors pointe. Contrairement au rollup de sessions, le rejouer plus souvent n'apporte rien — une date de naissance ne bouge pas, et la capture étant clé par jour, un second passage remplace simplement le premier (`DELETE` + `INSERT` dans une transaction, pour qu'un rejeu produisant moins de buckets ne laisse pas d'anciennes lignes derrière lui).

**Un seul scan produit toutes les dimensions** : `GROUP BY (sexe, âge exact, année d'inscription)` renvoie quelques centaines de lignes, repliées ensuite en PHP. L'index couvrant `idx_user_demographics` (`deleted_at, confirmed_at, sex, birthdate, joined_at`) maintient ce scan **dans l'index** — `Using index` vérifié à l'EXPLAIN, pas supposé — au lieu de parcourir les lignes complètes de `user`. Le scan passe ainsi de 5,3 s à **0,8 s**.

**Pas de bouton d'échantillonnage ici**, contrairement à `SESSION_STATS_SAMPLE_DIVISOR` : les effectifs les plus petits de cette capture sont précisément les bandes de mineurs, c'est-à-dire les chiffres qu'un échantillon rendrait ininterprétables. Périmètre scanné : comptes **vivants et confirmés** — une inscription non confirmée n'a pas prouvé qu'elle possède l'adresse, et la compter laisserait n'importe qui fausser les chiffres en saisissant une date de naissance dans un formulaire.

Purge des captures de plus de `DEMOGRAPHICS_KEEP_DAYS` (défaut 1095 ≈ 3 ans) dans le même tick.

---

### `POST /admin/stats/demographics/refresh`

Recalcule **immédiatement** la capture servie par l'endpoint ci-dessus, sans attendre le cron journalier ni disposer d'un accès shell.

Réutilise **exactement le service du worker** `bin/demographics-rollup.php` — aucune logique dupliquée, même convention que [`POST /admin/jobs/flush/{job}`](#post-adminjobsflushjob) : l'endpoint n'est qu'un déclencheur HTTP.

- **Auth** : token admin statique ou staff nominatif.
- **Action** : [RefreshUserDemographicsAction](../src/Http/Action/Admin/System/RefreshUserDemographicsAction.php).

Aucun paramètre, aucun corps.

**Idempotent par jour.** La capture est clé par date : rejouer **remplace** celle du jour (`DELETE` + `INSERT` dans une transaction) au lieu d'en empiler une seconde. Rafraîchir deux fois de suite ne produit aucune dérive — vérifié.

**Synchrone, et c'est un choix.** Le scan tient en ~1 s sur ~987 k comptes grâce à l'index couvrant ; une file asynchrone coûterait plus à opérer qu'elle ne ferait gagner, et l'opérateur veut voir le résultat, pas un identifiant de tâche.

**Réponse (200)** — le résumé *et* le rapport complet, pour qu'un seul aller-retour suffise à rafraîchir un panneau :

```json
{
  "captured": {
    "capturedOn": "2026-08-21", "rows": 226, "users": 987320,
    "withBirthdate": 987315, "withSex": 987314, "durationMs": 812
  },
  "report": { "…": "identique à GET /admin/stats/demographics" }
}
```

**409 — une capture est déjà en cours**

```json
{ "error": "A demographic capture is already running." }
```

Deux captures simultanées réécrivent les mêmes lignes : au mieux du travail en double, au pire un interblocage entre les deux transactions. Le service pose donc un **verrou nommé MySQL** (`hydrogen:demographics-rollup`, timeout 0). Un double-clic sur le bouton du BO, ou un clic pendant le passage du cron, repart en 409 **sans rien écrire** plutôt que de refaire le scan pour en jeter le résultat.

Le verrou est tenu sur la connexion `hxa` et relâché par MySQL si le process meurt — un plantage ne peut pas le coincer. Il est aussi relâché après un scan **en échec**, sans quoi une seule erreur bloquerait toutes les exécutions suivantes.

Côté cron, la réciproque est vraie : si le worker démarre pendant un rafraîchissement manuel, il journalise `already running, nothing to do` et **sort en 0**. Une course sans conséquence ne doit réveiller personne.

---

### `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 **7 bases MySQL** (`hxa`, `geo`, `hxa_bo`, `work`, `catalogue_swap`, `ai`, `content`), **Meilisearch** et le **serveur IA**, 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`) ; l'IA via un ping léger chronométré sur `AI_SERVER_BASE_URL` (cf. bloc `ai` ci-dessous).

**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` / `reachable` 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). |

> Le **serveur IA est un service auxiliaire** : son état **n'influence jamais** `status` ni `healthy`. Un serveur IA down laisse `status: "ok"` — seul le bloc `ai.reachable` passe à `false`.

**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 },
      "catalogue_swap": { "available": true, "latencyMs": 1.1, "error": null },
      "ai":             { "available": true, "latencyMs": 1.3, "error": null },
      "content":        { "available": true, "latencyMs": 0.9, "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 } }
  },
  "ai": {
    "reachable": true,
    "model": "mistralai/ministral-3-3b",
    "latencyMs": 42.5,
    "error": null
  }
}
```

| 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). |
| `ai.reachable` | `true` si le serveur IA a répondu `2xx` au ping. Sinon `false` (pastille rouge). |
| `ai.model` | modèle par défaut annoncé (`AI_DESCRIBE_MODEL`), `null` si injoignable. |
| `ai.latencyMs` | temps du ping en ms, `null` si la connexion a échoué (timeout/refus). |
| `ai.error` | raison de l'indisponibilité (message transport ou statut HTTP), `null` si OK. |

**Bloc `ai`** — sonde le **serveur d'inférence auxiliaire** (le même que `POST /admin/media/{hex}/describe-ai`). Un `GET` léger sur `{AI_SERVER_BASE_URL}{AI_HEALTH_PATH}` (défaut `/api/v1/models`), avec un **timeout court** (`AI_HEALTH_TIMEOUT_SECONDS`, défaut **2 s**) pour ne jamais ralentir le dashboard. `model` reprend `AI_DESCRIBE_MODEL`. **Fail-soft strict** : timeout, refus de connexion ou statut non-`2xx` → `{ "reachable": false, "model": null, "latencyMs": null|<ping>, "error": "…" }`, jamais de 500. Un consommateur qui ignore la clé `ai` (bloc simplement absent d'une version antérieure) ne subit **aucune régression** — le bloc est additif.

**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/workers` — activité des workers

Une ligne par worker : dernier passage, et surtout **s'il a l'air en retard**.

C'est la raison d'être de la table. Un worker qui échoue bruyamment se voit ; un worker qui **s'arrête** est ce qui a réellement coûté cher ici — la file de compteurs média est restée bloquée plusieurs jours, et le flush de notifications a brûlé son backlog contre un OneSignal mal configuré, les deux pendant que tous les tableaux de bord affichaient des chiffres normaux.

- **Auth** : token admin statique ou staff nominatif.
- **Fenêtre** : 24 h.

```json
{
  "overdue": 1,
  "failing": 1,
  "workers": [
    { "worker": "counters-flush", "environment": "hydrogen-preprod",
      "lastRun": "2026-08-29T13:14:56+00:00", "lastStatus": "ok",
      "silenceSeconds": 7200, "cadenceSeconds": 300, "overdue": true,
      "runs24h": 7, "failures24h": 0 },
    { "worker": "notifications-flush", "lastStatus": "ok",
      "silenceSeconds": 12, "cadenceSeconds": 120, "overdue": false,
      "runs24h": 12, "failures24h": 0 }
  ]
}
```

`overdue` se mesure contre la **cadence observée du worker lui-même** (moyenne des écarts entre ses passages), pas contre un planning configuré : il n'y a donc aucun crontab à recopier à la main, et la détection s'adapte si tu changes la fréquence. Seuil = 3 cadences manquées, avec un plancher de 5 minutes.

**La limite, à connaître :** ça détecte un worker qui **s'est arrêté**, jamais un worker qui n'a **jamais** été déployé. Rien ne peut signaler l'absence de ce qu'on ne lui a pas dit d'attendre. Un worker se déclare en rapportant.

---

### `GET /admin/workers/runs` — le journal détaillé

Ce que `GET /admin/workers` ne dit pas : ce qui s'est passé exactement, avec le `summary` rapporté par chaque worker et le message d'erreur le cas échéant.

Query : `?worker=`, `?status=` (`ok` | `idle` | `failed` | `skipped`), `?limit=` (défaut 50, plafond 500). Pas de pagination keyset : c'est un journal d'exploitation borné par la rétention, lu par un humain.

---

### `POST /admin/workers/runs` — un worker signale son passage

Seule voie possible pour les **workers distants** : `flush_worker.py` et `describe_worker.py` tournent sur une autre machine et n'ont aucun accès base.

| Champ | Requis | Sens |
|---|---|---|
| `worker` | oui | nom logique, ex. `notifications-flush`. Libre : un worker se déclare en rapportant, il n'y a pas de registre à tenir à jour |
| `status` | oui | `ok` \| `idle` \| `failed` \| `skipped` |
| `startedAt` | non | ISO-8601 ; défaut = maintenant. Une valeur illisible retombe sur maintenant plutôt que de rejeter un battement pour un détail de format |
| `durationMs` | non | défaut 0 |
| `summary` | non | objet libre : les compteurs propres au worker |
| `message` | non | raison de l'échec, tronquée à 2000 caractères |
| `environment`, `host` | non | la file `describe` étant partagée entre environnements, `dev` et `prod` ne doivent pas se lire comme une seule série |

Réponse `201` : `{ "id": 42, "worker": "…", "status": "…" }`. Erreurs `400` sur `worker` absent ou `status` hors liste.

**`idle` est un statut, pas une absence.** Un tick qui n'a rien trouvé à faire prouve quand même que le worker est vivant — c'est tout l'intérêt de la table. Un worker qui ne rapporterait qu'en cas de travail serait indiscernable d'un cron mort une nuit calme.

**Rétention auto-entretenue** : chaque insertion élague la queue de **son propre** worker au-delà de `WORKER_RUN_KEEP_DAYS` (30 j). Aucun cron de purge à programmer, et un worker bavard ne peut pas chasser l'historique d'un autre.

**Angle mort assumé** : si le serveur Hydrogen est injoignable, le worker ne peut pas signaler son échec — il faudrait justement le serveur pour le dire. C'est l'**absence** de battements qui rend alors la panne visible, via `overdue`.

---

### `GET /admin/system/logs`

Relecture **filtrée et paginée** du **journal applicatif** (`var/logs/app.sqlite`, alimenté par `SqliteLogger`). Miroir en lecture de ce qui est écrit automatiquement par les 3 gestionnaires d'erreurs HTTP : enquêter sur les **erreurs serveur 5xx** passées (quand, quelle route, quel statut, quelle exception) sans ouvrir le fichier SQLite à la main.

> **Périmètre actuel** : seules les erreurs `status ≥ 500` sont journalisées (les 4xx sont le flux normal et n'ajouteraient que du bruit — cf. `LogsHandledException`). Le niveau est donc toujours `error` aujourd'hui, mais le stockage et le filtre `level` sont prêts pour un élargissement ultérieur (warnings, événements métier) sans changement de schéma.

Cet endpoint étant un `GET`, il **n'est pas audité**. Tri implicite : `id DESC`. Pagination **keyset** mono-colonne sur `id` (auto-incrément monotone) : reporter `nextCursor.id` dans `?cursorId=`.

**Paramètres de requête** (tous optionnels, combinés en `AND`) :

| Param | Type | Description |
|---|---|---|
| `level` | string | niveau PSR-3 exact (`error`, `warning`, …) |
| `pathPrefix` | string | préfixe de chemin (match `LIKE` échappé, ex. `/api/media`) |
| `status` | int | code HTTP exact (ex. `500`) |
| `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":        1204,
      "createdAt": "2026-07-13T09:30:00+00:00",
      "level":     "error",
      "message":   "SQLSTATE[HY000] [2002] Connection refused",
      "method":    "POST",
      "path":      "/api/media",
      "status":    500,
      "context":   "{\"exception\":{\"class\":\"PDOException\",\"message\":\"…\",\"file\":\"/…/MediaRepository.php:88\"}}"
    }
  ],
  "nextCursor": { "id": 1204 }
}
```

`context` est le JSON compact du contexte PSR-3 non promu en colonne (l'exception est réduite à `{class, message, file:line}` ; jamais le secret brut). `nextCursor` est `null` dès qu'une page renvoie moins de `limit` lignes.

**Rétention** : la table est purgée par `bin/logs-purge.php` (cron), fenêtre `LOG_RETENTION_DAYS` (défaut 30 ; `0` = désactivé).

**Exemple** (les 500 sur `/api/media` depuis une date) :

```bash
curl -s -G -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  --data-urlencode "status=500" \
  --data-urlencode "pathPrefix=/api/media" \
  --data-urlencode "from=2026-07-01T00:00:00Z" \
  "http://hydrogen.dev.com/admin/system/logs"
```

---

### `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.

---

### Signalements par cible

Drill-down modération : tous les signalements visant **une** cible précise (un média, un compte ou un commentaire), au lieu de scanner la file globale et de filtrer côté client. Même base (`hxa_bo.report`), même shape de réponse et mêmes règles que `GET /admin/reports` : **non anonymisé** (`reporterUserId` brut, pour repérer un compte qui pilonne une cible), keyset `created_at DESC, id DESC`, envelope JSON plat `{ items, nextCursor }`.

| Endpoint | Cible | Auth |
|---|---|---|
| `GET /admin/users/{hex}/reports`    | compte (`target_type = user`)    | token statique **OU** staff nominatif |
| `GET /admin/media/{hex}/reports`    | média (`target_type = media`)    | token statique |
| `GET /admin/comments/{hex}/reports` | commentaire (`target_type = comment`) | token statique |

`{hex}` = id de la cible en hexadécimal (32 chars). Aucune vérification d'existence de la cible n'est faite : un signalement doit **survivre au hard-delete** du média/compte/commentaire qu'il vise, donc un id bien formé mais inconnu renvoie simplement une page vide (`items: []`).

**Query params**

| Param      | Valeurs                                                    | Défaut | Notes |
|------------|------------------------------------------------------------|--------|-------|
| `status`   | `pending` \| `reviewed` \| `action_taken` \| `dismissed`   | _aucun_ (tous statuts) | Filtre exact. Valeur inconnue silencieusement ignorée. Sert l'index `idx_target (target_type, target_id, status)`. |
| `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. |

**Réponse (200)** : identique à `GET /admin/reports` — `{ items: [ <report>, … ], nextCursor: { at, id } | null }`. Chaque `report` reprend les mêmes champs (`id`, `reporterUserId`, `targetType`, `targetId`, `reasonCode`, `details`, `status`, `resolvedByUserId`, `resolvedAt`, `resolutionNote`, `createdAt`, `updatedAt`).

**Exemple curl**

```bash
# Signalements ouverts visant un média donné
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/ab12…f0/reports?status=pending&limit=50"

# Page suivante
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/media/ab12…f0/reports?cursorAt=2026-06-13T12:34:56%2B00:00&cursorId=01a3471992e44c60a8f08321f713635a"
```

**Erreurs** : mêmes `400` de curseur que `GET /admin/reports` ; `403` si auth KO ; `404 { "error": "Target not found." }` uniquement si l'hex est malformé (le regex de route l'empêche déjà en pratique).

---

### Modération des commentaires

Endpoints réservés admin (token statique) pour **mesurer**, inspecter et
retirer les commentaires média (`media_comment`). JSON **plat** (pas JSON:API).
Toutes les mutations sont journalisées par `AdminAuditMiddleware`
(cf. [Journal d'audit](#journal-daudit)).

Différences clés avec la surface publique `/api/media/comments/*` :

- **Aucune rédaction** : le `body` est renvoyé **brut même pour un commentaire
  soft-deleted** (le public le masque en `null`). Un modérateur doit lire ce
  qui a réellement été écrit.
- **Ids en hex 32** (pas l'UUID à tirets du public), comme le reste de `/admin/*`.
- Le `DELETE` ignore la `COMMENT_DELETE_POLICY` (author/owner) : un modérateur
  peut retirer **n'importe quel** commentaire.

**Objet commentaire (plat)**

| Champ | Type | Sens |
|---|---|---|
| `id` | hex 32 | id du commentaire |
| `mediaId` | hex 32 | média porteur |
| `userId` | hex 32 | auteur |
| `parentId` | hex 32 \| null | parent direct (`null` = top-level) |
| `rootId` | hex 32 | ancêtre top-level (= `id` si top-level) |
| `depth` | int | 0 = top-level, +1 par niveau |
| `isTopLevel` | bool | `parentId === null` |
| `body` | string | texte **brut**, jamais masqué |
| `replyCount` | int | enfants directs non supprimés |
| `createdAt` | ISO-8601 | |
| `editedAt` | ISO-8601 \| null | |
| `deletedAt` | ISO-8601 \| null | tombstone soft-delete |
| `isDeleted` | bool | `deletedAt !== null` |

---

### `GET /admin/comments/stats`

Tableau de bord d'**engagement** des commentaires. Tout vient de la table
`hxa.media_comment` (une seule base) en quelques agrégats `GROUP BY` / `SUM`
conditionnels — endpoint basse fréquence, pas un hot path. JSON **plat**.

**Query**

| Param | Défaut | Sens |
|---|---|---|
| `days` | `30` | fenêtre de la courbe quotidienne, borné `1..366` |

**Réponse (200)**

- `totals` — compteurs globaux. `active` + `deleted` = `total` ;
  `topLevel` + `replies` = `active` (les soft-deleted sont exclus du
  découpage de forme pour que le ratio reflète la conversation vivante) ;
  `edited` = lignes vivantes amendées au moins une fois.
- `moderation` — ratios dérivés des `totals` (aucune requête supplémentaire) :
  `deletionRate` = `deleted / total` (part supprimée d'autorité / censurée),
  `editRate` = `edited / active` (part des vivants amendés par l'auteur),
  `replyRatio` = `replies / active` (conversation vs. commentaires racines).
  Chaque ratio est `null` quand son dénominateur vaut 0.
- `threadDepth` — répartition des commentaires **vivants** par profondeur
  (`0` = racine, `1` = réponse directe, …) plus la profondeur moyenne pondérée.
  Permet de voir jusqu'où descendent réellement les fils face au plafond
  `COMMENT_MAX_DEPTH`. `average` `null` si aucun commentaire vivant.
- `replyLatency` — délai entre une réponse **vivante** et la création de son
  parent (secondes) : `sample` (nombre de réponses mesurées), `averageSeconds`,
  `medianSeconds` (médiane via fonctions fenêtre MySQL 8). Le parent est conservé
  quel que soit son propre état de suppression. `average`/`median` `null` si
  `sample = 0`.
- `perDay` — créations par jour, **zéro-rempli** (chaque jour de la fenêtre
  présent, `count: 0` les jours creux) pour tracer une courbe continue.
  Compté par `created_at`, soft-delete ultérieur inclus (la création est le
  signal d'engagement).
- `topMedia` — 10 médias les plus commentés (commentaires **vivants** only),
  `name` via LEFT JOIN (`null` si le média a été purgé).
- `topCommenters` — 10 comptes les plus actifs (commentaires **vivants**
  only), `username`/`nickname` via LEFT JOIN (`null` si le compte a été purgé).

```json
{
  "totals": {
    "total": 12840,
    "active": 12190,
    "deleted": 650,
    "topLevel": 7320,
    "replies": 4870,
    "edited": 540
  },
  "moderation": {
    "deletionRate": 0.0506,
    "editRate": 0.0443,
    "replyRatio": 0.3995
  },
  "threadDepth": {
    "average": 0.71,
    "distribution": [
      { "depth": 0, "count": 7320 },
      { "depth": 1, "count": 3980 },
      { "depth": 2, "count": 890 }
    ]
  },
  "replyLatency": {
    "sample": 4870,
    "averageSeconds": 18240,
    "medianSeconds": 3120
  },
  "perDay": {
    "days": 30,
    "from": "2026-05-30",
    "to": "2026-06-28",
    "total": 1840,
    "series": [
      { "date": "2026-05-30", "count": 61 },
      { "date": "2026-05-31", "count": 0 }
    ]
  },
  "topMedia": [
    { "mediaId": "9f8e7d6c5b4a39281706f5e4d3c2b1a0", "name": "Sunset over Bali", "count": 184 }
  ],
  "topCommenters": [
    { "userId": "1122334455667788990011223344556677", "username": "marco", "nickname": "Marco P.", "count": 312 }
  ]
}
```

```bash
# Fenêtre par défaut (30 jours)
curl -s "$BASE/admin/comments/stats" -H "$AUTH"
# Fenêtre d'un an
curl -s "$BASE/admin/comments/stats?days=366" -H "$AUTH"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Invalid days." }` | `days` non numérique |
| `403` | `{ "error": "..." }` | auth KO |

---

### `GET /admin/comments/recent`

Firehose **global** : les N derniers commentaires, **tous médias ET tous
utilisateurs confondus**, **y compris les soft-deleted** (`body` brut conservé),
en ordre anté-chronologique. Keyset sur `(created_at DESC, id DESC)`, adossé à
l'index `idx_mc_created (created_at, id)`
(migration `2026_07_13_120000_add_media_comment_created_index.sql`).

Sert l'outil de modération « derniers commentaires » du back-office Hyperion,
qui n'a pas d'autre point d'entrée global (les autres listings sont scopés par
média ou par utilisateur).

**Query** (tous optionnels)

| Param | Défaut | Sens |
|---|---|---|
| `cursorAt` | — | ISO-8601, `created_at` de la dernière ligne de la page |
| `cursorId` | — | hex 32, id de cette même ligne (tiebreaker) |
| `limit` | `50` | borné `1..100` |

Les deux moitiés du curseur vont **ensemble** ; une seule ⇒ `400`.

**Réponse (200)** — forme de `GET /admin/media/{hex}/comments`, **enrichie** de
libellés d'affichage joints (LEFT JOIN `user` / `media`) pour éviter au
back-office une résolution par auteur/média : `authorUsername`, `authorNickname`
et `mediaName`. Ces trois champs valent `null` si l'auteur ou le média a été
purgé. Ils sont propres à ce firehose (absents du détail unitaire).

```json
{
  "items": [
    {
      "id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
      "mediaId": "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
      "userId": "1122334455667788990011223344556677",
      "parentId": null,
      "rootId": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
      "depth": 0,
      "isTopLevel": true,
      "body": "Superbe cliché !",
      "replyCount": 2,
      "createdAt": "2026-06-20T14:03:00+00:00",
      "editedAt": null,
      "deletedAt": null,
      "isDeleted": false,
      "authorUsername": "jdupont",
      "authorNickname": "Jean D.",
      "mediaName": "Coucher de soleil sur la baie"
    }
  ],
  "nextCursor": { "at": "2026-06-20T14:03:00+00:00", "id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9" }
}
```

`nextCursor` vaut `null` sur la dernière page.

```bash
# Première page
curl -s "$BASE/admin/comments/recent?limit=50" -H "$AUTH"
# Page suivante
curl -s "$BASE/admin/comments/recent?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=0a1b..." -H "$AUTH"
```

### `GET /admin/media/{hex}/comments`

Firehose d'un média : **tous** les commentaires **quelle que soit la
profondeur** (top-level ET réponses inline), **y compris les soft-deleted**,
en ordre anté-chronologique. Keyset sur `(created_at DESC, id DESC)`.

**Query** (tous optionnels)

| Param | Défaut | Sens |
|---|---|---|
| `cursorAt` | — | ISO-8601, `created_at` de la dernière ligne de la page |
| `cursorId` | — | hex 32, id de cette même ligne (tiebreaker) |
| `limit` | `50` | borné `1..100` |

Les deux moitiés du curseur vont **ensemble** ; une seule ⇒ `400`.

**Réponse (200)**

```json
{
  "items": [
    {
      "id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
      "mediaId": "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
      "userId": "1122334455667788990011223344556677",
      "parentId": null,
      "rootId": "0a1b2c3d4e5f60718293a4b5c6d7e8f9",
      "depth": 0,
      "isTopLevel": true,
      "body": "Superbe cliché !",
      "replyCount": 2,
      "createdAt": "2026-06-20T14:03:00+00:00",
      "editedAt": null,
      "deletedAt": null,
      "isDeleted": false
    }
  ],
  "nextCursor": { "at": "2026-06-20T14:03:00+00:00", "id": "0a1b2c3d4e5f60718293a4b5c6d7e8f9" }
}
```

`nextCursor` vaut `null` sur la dernière page.

```bash
# Première page
curl -s "$BASE/admin/media/9f8e7d6c5b4a39281706f5e4d3c2b1a0/comments?limit=50" -H "$AUTH"
# Page suivante
curl -s "$BASE/admin/media/9f8e.../comments?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=0a1b..." -H "$AUTH"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | curseur partiel |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | `cursorAt` illisible |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | `cursorId` malformé |
| `404` | `{ "error": "Media not found." }` | hex de média malformé |
| `403` | `{ "error": "..." }` | auth KO |

> Note : un média sans commentaire renvoie `items: []` (pas `404`). Le `404`
> ne couvre que le hex malformé — il n'y a pas de vérification d'existence du
> média (la liste vide est indiscernable d'un média inexistant, ce qui est
> acceptable côté back-office).

---

### `GET /admin/media/{hex}/reactions`

Firehose d'un média : **toutes** les réactions actives, `like` **et** `dislike`
entrelacés en ordre anté-chronologique, avec le `userId` du réacteur. Keyset
sur `(created_at DESC, user_id DESC)`. Pendant « réactions » du firehose
commentaires ci-dessus.

**Query** (tous optionnels)

| Param | Défaut | Sens |
|---|---|---|
| `value` | — | `like` \| `dislike` — restreint à un seul type |
| `cursorAt` | — | ISO-8601, `created_at` de la dernière ligne de la page |
| `cursorId` | — | hex 32, `user_id` de cette même ligne (tiebreaker) |
| `limit` | `50` | borné `1..100` |

Les deux moitiés du curseur vont **ensemble** ; une seule ⇒ `400`.

**Réponse (200)**

```json
{
  "items": [
    {
      "mediaId": "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
      "userId": "1122334455667788990011223344556677",
      "value": "like",
      "createdAt": "2026-06-20T14:03:00+00:00"
    }
  ],
  "nextCursor": { "at": "2026-06-20T14:03:00+00:00", "id": "1122334455667788990011223344556677" }
}
```

`nextCursor` vaut `null` sur la dernière page.

```bash
# Toutes les réactions, première page
curl -s "$BASE/admin/media/9f8e7d6c5b4a39281706f5e4d3c2b1a0/reactions?limit=50" -H "$AUTH"
# Uniquement les dislikes
curl -s "$BASE/admin/media/9f8e.../reactions?value=dislike" -H "$AUTH"
# Page suivante
curl -s "$BASE/admin/media/9f8e.../reactions?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=1122..." -H "$AUTH"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "value must be one of: like, dislike." }` | `value` invalide |
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | curseur partiel |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | `cursorAt` illisible |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | `cursorId` malformé |
| `404` | `{ "error": "Media not found." }` | hex de média malformé |
| `403` | `{ "error": "..." }` | auth KO |

> Réserve FLOW : un un-like **supprime** sa ligne `media_reaction` ; une
> réaction annulée n'apparaît donc plus ici. Cette liste est l'état **courant**
> des réactions actives, pas un journal d'événements — les compteurs à vie
> (`likesCount`/`dislikesCount`) restent autoritatifs dans `media_stats`. Comme
> pour les commentaires, un média sans réaction renvoie `items: []` (pas `404`).

---

### `GET /admin/comments/{hex}`

Fiche brute d'un commentaire unique. Expose toutes les colonnes, dont le `body`
d'un commentaire soft-deleted et les ids de threading.

**Réponse (200)** : l'objet commentaire plat décrit ci-dessus.

```bash
curl -s "$BASE/admin/comments/0a1b2c3d4e5f60718293a4b5c6d7e8f9" -H "$AUTH"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Comment not found." }` | row absente ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

---

### `DELETE /admin/comments/{hex}`

Soft-delete de modération : pose `deleted_at` et décrémente les compteurs
(`reply_count` du parent si c'est une réponse, sinon
`media_stats.comments_count`). La ligne est **conservée** pour ne pas
orpheliner les réponses enfants. Ignore la `COMMENT_DELETE_POLICY`.

**Réponse (200)** : l'objet commentaire plat **post-état** (`isDeleted: true`,
`deletedAt` renseigné, `body` toujours présent).

```bash
curl -s -X DELETE "$BASE/admin/comments/0a1b2c3d4e5f60718293a4b5c6d7e8f9" -H "$AUTH"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Comment not found." }` | row absente ou hex malformé |
| `410` | `{ "error": "Comment already deleted." }` | déjà soft-deleted (idempotence stricte) |
| `403` | `{ "error": "..." }` | auth KO |

**Notes**

- Aucune notification n'est émise (ni à l'auteur, ni au propriétaire du média) :
  la modération reste invisible côté produit, comme pour les signalements.
- Pas de hard-delete : la suppression définitive d'un thread se fait via le
  hard-delete du média (`DELETE /admin/media/{hex}`, cascade FK) ou un script
  de purge dédié.
- Réversible via `POST /admin/comments/{hex}/restore` (ci-dessous).

---

### `POST /admin/comments/{hex}/restore`

Annule un soft-delete : efface le tombstone `deleted_at` et **ré-incrémente**
les compteurs que la suppression avait décrémentés (`reply_count` du parent
pour une réponse, sinon `media_stats.comments_count`).

**Réponse (200)** : l'objet commentaire plat **post-état** (`isDeleted: false`,
`deletedAt: null`).

```bash
curl -s -X POST "$BASE/admin/comments/0a1b2c3d4e5f60718293a4b5c6d7e8f9/restore" -H "$AUTH"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Comment not found." }` | row absente ou hex malformé |
| `409` | `{ "error": "Comment is not deleted." }` | commentaire déjà actif (rien à restaurer) |
| `403` | `{ "error": "..." }` | auth KO |

> Note : un commentaire dont le **parent** est encore supprimé peut être
> restauré — `reply_count` est un cache, la cohérence d'affichage reste gérée
> côté rendu du thread.

---

### `POST /admin/comments/{hex}/censor`

Censure éditoriale : **écrase** définitivement le `body` par le message
`COMMENT_CENSOR_MESSAGE` (défaut `[Commentaire modéré]`). Contrairement au
soft-delete, le commentaire **reste visible** — la structure du thread est
préservée et un lecteur voit la notice de modération à la place du texte
original.

Le texte original n'est **pas** archivé : l'opération est **irréversible** (il
n'existe pas de pendant « uncensor » ; pour re-masquer sans effacer, utiliser
`DELETE /admin/comments/{hex}`). Les compteurs (`reply_count`,
`media_stats.comments_count`) restent **inchangés** — le commentaire compte
toujours. `edited_at` n'est **pas** touché (ce n'est pas une édition d'auteur).

**Réponse (200)** : l'objet commentaire plat **post-état** — `body` vaut le
message de censure, `isDeleted` inchangé.

```bash
curl -s -X POST "$BASE/admin/comments/0a1b2c3d4e5f60718293a4b5c6d7e8f9/censor" -H "$AUTH"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Comment not found." }` | row absente ou hex malformé |
| `403` | `{ "error": "..." }` | auth KO |

**Notes**

- Idempotent : re-censurer réécrit simplement le même message (`200`).
- Aucune notification émise (comme le soft-delete de modération).
- Le message de remplacement est stocké **tel quel** dans `body` (chaîne figée,
  pas de résolution i18n au rendu) : il s'affiche à l'identique pour toutes les
  locales. Ajuster `COMMENT_CENSOR_MESSAGE` en amont si besoin.

---

### 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,
      "banReason":          null,         // raison INTERNE du ban (back-office only), null hors ban
      "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,
      "avatarUrl":          "https://cdn.example.com/user/default-avatar.webp",
      "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/{userId}` — même enveloppe (`{ jsonapi, data:{ type:"users", id, attributes } }`), enrichie de **tous** les champs masqués côté public. La ressource publique n'expose qu'un **sous-ensemble sûr sans PII** (via `PublicUserSummarySerializer`) ; côté admin on part au contraire de la forme d'attributs **complète** (via `UserResourceSerializer` — `sex` en slug i18n, dates riches, stats incluses, **plus** `email` et les autres champs personnels que le public masque), puis on back-fill **tous** les champs internes bruts (via `AdminUserSerializer`) 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`/`banReason`, `deletedAt`/`isDeleted`, `purgedAt`/`isPurged`, `passwordSetAt`/`hasPassword`, `avatarUrl`/`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}/official`

Désigne (ou retire) un compte comme appartenant à l'**équipe**. Un compte officiel **ne peut pas être bloqué** par les utilisateurs : il conserve sa portée sur tout le monde, qui ne peut pas se soustraire à ses commentaires, réactions ou abonnements.

**Corps** : `{ "isOfficial": true | false }`

- Volontairement **distinct de `verified`** : le badge bleu atteste l'authenticité, ce drapeau accorde un pouvoir. Les déduire l'un de l'autre rendrait imblocable tout créateur vérifié. (`user_type` n'était pas exploitable non plus : la colonne est uniforme sur toute la base.)
- **Effet de bord à la promotion** : tous les blocages **visant** ce compte sont levés, et leur nombre est renvoyé dans `liftedBlocks`. Sans cela, la règle serait fausse pour les utilisateurs qui s'étaient déjà protégés. La rétrogradation ne les restaure pas.
- **Idempotent** : un PUT avec la valeur courante renvoie `transition: "none"` et n'écrit rien.
- Journalisé dans `user_action_log` (action `user.official.set`) — désigner un compte d'équipe est toujours imputable à un opérateur.

**Réponses** : `200 { status, userId, isOfficial, transition, liftedBlocks }` · `400` corps invalide · `404` compte inconnu.

---

### `GET /admin/users/{hex}/blocks`

Les blocages autour d'un compte, **dans les deux sens** — contrairement à `GET /api/users/me/blocks` côté utilisateur, qui ne révèle jamais que le sens sortant.

Cette asymétrie est la raison d'être de la vue admin : pour un modérateur, un compte **bloqué par vingt personnes** raconte une tout autre histoire qu'un compte qui en a bloqué vingt.

Chaque sens **paginé indépendamment** en keyset sur `(created_at DESC, peer_id DESC)` — même convention que les autres batches admin. Les `total` restent exacts quelle que soit la page, donc le volume complet est toujours visible.

**Query** (tous optionnels) :

| Paramètre | Défaut | Description |
|-----------|--------|-------------|
| `limit`             | `100` | 1..500, appliqué à **chaque** sens. |
| `blockingCursorAt`  | —     | ISO-8601 : `blockedAt` de la dernière ligne du sens `blocking`. |
| `blockingCursorId`  | —     | hex 32 : id du pair de cette même ligne (tiebreaker). |
| `blockedByCursorAt` | —     | idem pour le sens `blockedBy`. |
| `blockedByCursorId` | —     | idem pour le sens `blockedBy`. |

Les deux moitiés d'un curseur vont **ensemble** : n'en fournir qu'une ⇒ `400` (nommant le sens).

```json
{
  "userId": "<hex>",
  "blocking":  {
    "total": 3,
    "items": [ { "userId", "username", "displayName", "isVerified", "isOfficial", "blockedAt" } ],
    "nextCursor": { "at": "<ISO>", "id": "<hex>" }
  },
  "blockedBy": { "total": 1, "items": [ … ], "nextCursor": null }
}
```

`nextCursor` vaut `null` quand la dernière page d'un sens est atteinte ; sinon réinjecter `at`/`id` dans `{blocking,blockedBy}CursorAt`/`CursorId` pour la page suivante de ce sens. Un pair supprimé depuis reste listé avec ses champs à `null` : c'est le blocage qui est l'objet de l'enquête, pas le compte derrière.

---

### `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", "reason": "spam massif" }
```

- `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`).
- `reason` = **raison interne** (note libre, ≤ 500 caractères) persistée sur `user.ban_reason`. Visible **back-office uniquement** (`AdminUserSerializer` → `GET /admin/users` et `GET /admin/users/{hex}`), jamais renvoyée par les serializers publics, jamais indexée dans Meilisearch, jamais exposée à l'utilisateur banni. Trim + `""` → `null`. Elle est aussi journalisée dans `user_action_log` (champ `reason`). Effacée automatiquement à l'unban.

**Side-effects**

- Un `UPDATE user` (`banned_until` + `ban_reason` + 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>", "banReason": "spam massif", "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é |
| `422` | `{ "error": "Ban 'reason' must be a string of at most 500 characters." }` | `reason` non-string ou > 500 caractères |
| `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) avec raison interne
curl -X PUT \
  -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"until": "2026-06-26T00:00:00+00:00", "reason": "spam massif"}' \
  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. `banReason` reste **interne** : jamais sur les ressources publiques.
- 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` **et** en effaçant la raison interne `user.ban_reason`. **Réservé admin**. Pas de body.

**Side-effects** : un seul `UPDATE user` (`banned_until` + `ban_reason` remis à `NULL`, + 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.

---

### `GET /admin/users/{hex}/comments`

Investigation d'abus : **tous les commentaires écrits par ce compte**, **tous
médias confondus**, **soft-deleted inclus** (`body` brut). Newest-first, keyset
sur `(created_at DESC, id DESC)` — adossé à `idx_mc_user_created`.

Renvoie l'objet commentaire plat décrit dans
[Modération des commentaires](#modération-des-commentaires).

**Query** (tous optionnels)

| Param | Défaut | Sens |
|---|---|---|
| `cursorAt` | — | ISO-8601, `created_at` de la dernière ligne de la page |
| `cursorId` | — | hex 32, id de cette même ligne (tiebreaker) |
| `limit` | `50` | borné `1..100` |

Les deux moitiés du curseur vont **ensemble** ; une seule ⇒ `400`.

**Réponse (200)** : enveloppe `{ items: [<comment plat>, …], nextCursor }`
(même forme que `GET /admin/media/{hex}/comments`).

```bash
# Première page
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/comments?limit=50"
# Page suivante
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/comments?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=0a1b..."
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | curseur partiel |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | `cursorAt` illisible |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | `cursorId` malformé |
| `404` | `{ "error": "User not found." }` | hex de compte malformé |
| `403` | `{ "error": "..." }` | auth KO |

> Note : rattaché à la surface `/admin/users/*` → auth **HYBRIDE** (token
> statique OU staff nominatif), contrairement aux endpoints
> `/admin/comments/*` et `/admin/media/{hex}/comments` (token statique seul).
> Un compte sans commentaire renvoie `items: []` (pas `404`).

---

### `GET /admin/users/{hex}/reactions`

Investigation d'abus : **toutes les réactions posées par ce compte** (`like`
**et** `dislike` entrelacés), tous médias confondus, avec le `mediaId` visé.
Newest-first, keyset sur `(created_at DESC, media_id DESC)` — adossé à
`idx_mr_user_value_created`. Pendant côté **auteur** de
`GET /admin/media/{hex}/reactions`.

En plus de la page, renvoie le **bilan à vie** du compte (`COUNT` indexé,
indépendant du curseur) : `totals.likes` / `totals.dislikes` et un `sentiment`
dérivé — répond directement à « ce compte est-il plutôt positif ou négatif ? ».
Le bilan couvre **toujours les deux valeurs**, même quand `value` filtre la
liste.

| `sentiment` | Condition |
|---|---|
| `positive` | `likes > dislikes` |
| `negative` | `dislikes > likes` |
| `neutral`  | égalité (y compris `0/0`) |

**Query** (tous optionnels)

| Param | Défaut | Sens |
|---|---|---|
| `value` | — | `like` \| `dislike` — restreint la **liste** (les `totals` restent globaux) |
| `cursorAt` | — | ISO-8601, `created_at` de la dernière ligne de la page |
| `cursorId` | — | hex 32, `media_id` de cette même ligne (tiebreaker) |
| `limit` | `50` | borné `1..100` |

Les deux moitiés du curseur vont **ensemble** ; une seule ⇒ `400`.

**Réponse (200)**

```json
{
  "items": [
    {
      "mediaId": "9f8e7d6c5b4a39281706f5e4d3c2b1a0",
      "userId": "d26d1600cde54bd095e09f8b68ace05f",
      "value": "like",
      "createdAt": "2026-06-20T14:03:00+00:00"
    }
  ],
  "totals": { "likes": 123, "dislikes": 45 },
  "sentiment": "positive",
  "nextCursor": { "at": "2026-06-20T14:03:00+00:00", "id": "9f8e7d6c5b4a39281706f5e4d3c2b1a0" }
}
```

`nextCursor` vaut `null` sur la dernière page.

```bash
# Toutes les réactions du compte + bilan, première page
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/reactions?limit=50"
# Uniquement les dislikes (totals restent globaux)
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/reactions?value=dislike"
# Page suivante
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/reactions?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=9f8e..."
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "value must be one of: like, dislike." }` | `value` inconnu |
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | curseur partiel |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | `cursorAt` illisible |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | `cursorId` malformé |
| `404` | `{ "error": "User not found." }` | hex de compte malformé ou compte absent |
| `403` | `{ "error": "..." }` | auth KO |

> Note : rattaché à la surface `/admin/users/*` → auth **HYBRIDE** (token
> statique OU staff nominatif). Un compte sans réaction renvoie `items: []`,
> `totals: {likes:0, dislikes:0}`, `sentiment: "neutral"` (pas `404`). Un
> un-like retire la ligne : la liste et les totaux reflètent l'état **courant**,
> pas un historique.

---

### `GET /admin/users/{hex}/referrals`

**Filleuls (parrainage) d'un compte** pour l'exploration de son réseau, conçu
pour rester **performant quelle que soit la taille du réseau** grâce à une
**expansion paresseuse**. Ne renvoie que les **filleuls directs** du `hex`
demandé (une page keyset, newest-first sur `(created_at DESC, referred_id DESC)`,
adossé à l'index `sponsor_id`). Chaque ligne porte **ses propres compteurs**
(`direct` / `valid`) pour savoir si le nœud est **dépliable** sans charger son
sous-arbre : le client re-appelle ce même endpoint avec le hex d'un enfant pour
descendre d'un niveau. Le coût est borné par la taille de page, jamais par celle
du réseau.

Quand `stats=1` (ouverture de l'onglet sur le profil, page 1), la réponse porte
en plus un bloc **`stats`** (code de parrainage, compteurs directs/valides,
**réseau total récursif** via CTE bornée en profondeur) et une **`timeline`**
d'inscriptions quotidiennes. Les expansions de sous-nœuds et la pagination
omettent ces blocs lourds pour rester bon marché.

**Query** (tous optionnels)

| Param | Défaut | Sens |
|---|---|---|
| `stats` | — | `1` — inclut les blocs `stats` + `timeline` (racine uniquement) |
| `days` | `30` | borné `1..180` — fenêtre de la `timeline` (si `stats=1`) |
| `cursorAt` | — | ISO-8601, `created_at` de la dernière arête de la page |
| `cursorId` | — | hex 32, `referred_id` de cette même arête (tiebreaker) |
| `limit` | `40` | borné `1..100` |

Les deux moitiés du curseur vont **ensemble** ; une seule ⇒ `400`.

**Réponse (200)**

```json
{
  "stats": {
    "code": "ABCD1234",
    "direct": 12,
    "valid": 8,
    "networkTotal": 47
  },
  "timeline": {
    "days": 30,
    "from": "2026-06-14T00:00:00+00:00",
    "to": "2026-07-13T23:59:59+00:00",
    "series": [ { "date": "2026-06-14", "count": 2 } ]
  },
  "children": {
    "items": [
      {
        "id": "d26d1600cde54bd095e09f8b68ace05f",
        "username": "alice",
        "displayName": "Alice Martin",
        "avatarUrl": "https://cdn.example.com/user/…/avatar.webp",
        "joinedAt": "2026-05-02T10:11:12+00:00",
        "referredAt": "2026-06-20T14:03:00+00:00",
        "isConfirmed": true,
        "direct": 3,
        "valid": 2
      }
    ],
    "total": 12,
    "nextCursor": { "at": "2026-06-20T14:03:00+00:00", "id": "d26d1600cde54bd095e09f8b68ace05f" }
  }
}
```

Les blocs `stats` et `timeline` ne sont présents **que** si `stats=1`.
`nextCursor` vaut `null` sur la dernière page. `series` est **zéro-rempli** sur
toute la fenêtre (courbe continue), du plus ancien au plus récent.

```bash
# Filleuls directs + stats + timeline, première page (ouverture de l'onglet)
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/referrals?stats=1"
# Déplier un enfant (une requête indexée, sans stats)
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/<hexEnfant>/referrals"
# Page suivante des filleuls directs
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/referrals?cursorAt=2026-06-20T14:03:00%2B00:00&cursorId=d26d..."
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Both cursorAt and cursorId must be supplied together." }` | curseur partiel |
| `400` | `{ "error": "cursorAt is not a valid datetime." }` | `cursorAt` illisible |
| `400` | `{ "error": "cursorId is not a valid hex UUID." }` | `cursorId` malformé |
| `404` | `{ "error": "User not found." }` | hex de compte malformé ou compte absent |
| `403` | `{ "error": "..." }` | auth KO |

> Note : rattaché à la surface `/admin/users/*` → auth **HYBRIDE** (token
> statique OU staff nominatif). JSON **plat** (pas JSON:API). Un compte sans
> filleul renvoie `children.items: []`, `children.total: 0` (pas `404`). Le
> `networkTotal` est borné en profondeur (`NETWORK_MAX_DEPTH`) pour éviter les
> cycles pathologiques et garder la CTE bon marché.

---

### `GET /admin/users/{hex}/sessions`

**Inventaire complet des sessions** d'un compte — actives **et** expirées non
encore purgées, les plus récemment utilisées en tête. Contrairement au
self-service `GET /api/auth/sessions` (filtré sur `expires_at > now`), l'admin
voit tout, pour investiguer un compte compromis. Chaque ligne porte un drapeau
`isActive` dérivé à la lecture.

> **Le jeton de session n'est JAMAIS exposé** — ni en clair (il n'existe qu'au
> moment du login), ni son hash SHA-256. Seules les métadonnées sortent :
> device (`userAgent`), `ipAddress` (dé-packée via `inet_ntop`), horodatages.

Pas de pagination : le nombre de sessions par compte est borné (fenêtre
glissante + purge).

**Réponse (200)**

```json
{
  "items": [
    {
      "id": "3f1c9e2a-7b4d-4e18-9a55-0c2b6d8e1f00",
      "createdAt": "2026-06-01T10:00:00+00:00",
      "lastUsedAt": "2026-07-05T15:30:00+00:00",
      "expiresAt": "2026-08-04T10:00:00+00:00",
      "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) …",
      "device": {
        "browser": { "name": "Safari", "version": "17.5", "iconUrl": "…/devices/browsers/safari.svg" },
        "os":      { "name": "iOS",    "version": "17.5", "iconUrl": "…/devices/os/ios.svg" },
        "model":   "iPhone",
        "isMobile": true
      },
      "ipAddress": "192.168.1.1",
      "country": { "code": "FR", "name": "France", "emoji": "🇫🇷", "flagUrl": "…/assets/images/flags/fr.svg" },
      "isActive": true
    }
  ],
  "totals": { "all": 5, "active": 3, "expired": 2 }
}
```

`device` est **dérivé du `userAgent` à la lecture** (rien de stocké), bloc identique à celui de `GET /api/auth/sessions` — la surface admin reste un sur-ensemble du self-service. Il garde toujours la même forme : un client non-navigateur donne des `name`/`version`/`iconUrl` à `null` plutôt qu'une clé absente. Voir la section correspondante de `docs/api.md` pour les slugs d'icônes et la normalisation des libellés.

`country` est en revanche **stocké** (colonne `user_session.country`), résolu une seule fois à la création de la session sur l'adresse complète avant son masquage. `null` = inconnu : session antérieure à la migration, adresse absente de la base de géolocalisation, ou fichier MMDB non déployé. Le rattrapage des sessions existantes se fait avec `bin/session-country-backfill.php`, la base se rafraîchit avec `bin/geoip-update.php` (mensuel). Le drapeau est fourni sous deux formes — `emoji` calculé depuis le code (sans asset, mais non rendu sous Windows) et `flagUrl` (fichier par code ISO en minuscules) : un back-office Windows doit utiliser la seconde.

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/sessions"
```

**Erreurs** : `404 { "error": "User not found." }` (hex malformé ou compte
absent) ; `403` auth KO. Un compte sans session renvoie `items: []`,
`totals: {all:0, active:0, expired:0}`.

---

### `DELETE /admin/users/{hex}/sessions/{id}`

**Révoque une session précise** (jeton compromis, appareil perdu). Hard delete
de la ligne `user_session` : le prochain appel portant ce jeton est rejeté à
l'authentification. La session doit appartenir au compte `{hex}`, sinon `404`.

`{id}` est un **UUID dashé** (même forme que `GET /api/auth/sessions`), à ne pas
confondre avec le `{hex}` du compte (32 hex sans tirets).

**Réponse (200)**

```json
{ "status": "revoked", "userId": "<hex>", "sessionId": "3f1c9e2a-7b4d-4e18-9a55-0c2b6d8e1f00" }
```

```bash
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/sessions/3f1c9e2a-7b4d-4e18-9a55-0c2b6d8e1f00"
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Path parameter {id} must be a valid UUID." }` | `{id}` malformé |
| `404` | `{ "error": "User not found." }` | compte absent |
| `404` | `{ "error": "Session not found." }` | session inexistante ou appartenant à un autre compte |

---

### `DELETE /admin/users/{hex}/sessions`

**Révoque TOUTES les sessions** du compte d'un coup (compte compromis, ménage de
sécurité). Hard delete de toutes les lignes `user_session` — actives comme
expirées. Même mécanique que le side-effect d'un ban, mais **isolée** : ne touche
pas `banned_until`, le compte pourra se reconnecter.

**Réponse (200)**

```json
{ "status": "revoked", "userId": "<hex>", "sessionsRevoked": 5 }
```

```bash
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/users/d26d1600cde54bd095e09f8b68ace05f/sessions"
```

**Erreurs** : `404 { "error": "User not found." }` ; `403` auth KO.

> Note : les trois endpoints sessions sont rattachés à la surface
> `/admin/users/*` → auth **HYBRIDE** (token statique OU staff nominatif). Les
> deux révocations sont **journalisées** dans `hxa_bo.user_action_log`
> (`user.session.revoke` / `user.session.revoke_all`) — cf.
> [`GET /admin/users/{hex}/history`](#get-adminusershexhistory).

---

### `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}`, `/go/brand/{code}` (+ `/media/{mediaId}`) ou les endpoints de mint sans redirection `POST /api/offers/{id}/clicks` / `POST /api/brands/{code}/clicks` (voir `docs/api.md`) : un `clickRef` opaque (UUIDv7 en 32 hex) est persisté dans `tracking_click` puis injecté dans l'URL marchande comme `subid`. **Seul** ce `clickRef` opaque sort du système — aucun `userId`/PII n'est exposé au partenaire.

Le `targetType` discrimine la cible : `offer` (`targetId` = id 32-hex de l'offre) ou `brand` (`targetId` = **code** de la marque, ex. `fram`).

**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), `null` si le clic n'avait **pas** de contexte média (clics marque `global`/sans-média, `mediaId` optionnel) |
| `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"
```

---

### `GET /admin/newsletter/subscribers/{id}`

Détail unitaire d'un inscrit (enquête / support). Même projection plate que le listing — les hash de tokens ne sont **jamais** exposés. `{id}` est l'UUID hex (32 chars) tel que renvoyé par le listing.

**Réponse (200)** : l'objet `subscriber` seul (même forme que les `items[]` du listing).

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Subscriber not found." }` | id inconnu |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/newsletter/subscribers/d26d1600cde54bd095e09f8b68ace05f
```

---

### `DELETE /admin/newsletter/subscribers/{id}`

Désinscription **forcée** (RGPD / modération). Contrairement au flux public piloté par token, l'opérateur agit de sa propre autorité (mutation **journalisée** par l'audit admin). C'est un **soft opt-out** : la ligne reste en base (statut `unsubscribed`, `unsubscribed_at` horodaté) — l'historique de consentement est conservé. **Idempotent** : ré-appeler ré-horodate simplement.

**Réponse (200)** : le `subscriber` mis à jour (statut `unsubscribed`).

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Subscriber not found." }` | id inconnu |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X DELETE -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/newsletter/subscribers/d26d1600cde54bd095e09f8b68ace05f
```

---

### `POST /admin/newsletter/subscribers/{id}/resend-confirmation`

Renvoi manuel de l'e-mail de double opt-in pour un inscrit **en attente** (premier envoi échoué, lien expiré). Ré-arme un token de confirmation frais (rotation) et ré-envoie, en contournant le throttle/validation public (l'adresse est déjà vérifiée). **Uniquement** valable pour un statut `pending`.

**Réponse (200)**

```jsonc
{ "emailSent": true }  // le token est ré-armé quoi qu'il arrive ; `false` = échec SMTP transitoire (réessayable)
```

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `404` | `{ "error": "Subscriber not found." }` | id inconnu |
| `409` | `{ "error": "Confirmation can only be resent to a pending subscriber.", "status": "confirmed" }` | pas en attente |
| `403` | `{ "error": "..." }` | auth KO |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/newsletter/subscribers/d26d1600cde54bd095e09f8b68ace05f/resend-confirmation
```

---

### `GET /admin/newsletter/stats`

Tableau de bord de la liste de diffusion, agrégé en **un seul appel**. Lecture seule.

- `confirmationRate` = `confirmed / (confirmed + unsubscribed)` — parmi ceux qui ont **agi** sur le lien (les `pending` ne pèsent pas). `null` tant que personne n'a agi.
- `bySource` / `byLocale` : ventilation d'acquisition, `NULL` replié sous `(unknown)`.
- `recent` : fenêtres glissantes 7 / 30 jours (inscriptions / confirmations / désinscriptions).
- `daily` : série **dense** (zéro-remplie) des inscriptions sur 30 jours.

**Réponse (200)**

```jsonc
{
  "counts":           { "pending": 3, "confirmed": 42, "unsubscribed": 5, "total": 50 },
  "confirmationRate": 0.8936,
  "bySource":         { "footer": 30, "popup": 12, "(unknown)": 8 },
  "byLocale":         { "fr": 40, "en": 9, "(unknown)": 1 },
  "recent": {
    "subscriptions":   { "last7": 4, "last30": 18 },
    "confirmations":   { "last7": 3, "last30": 15 },
    "unsubscriptions": { "last7": 1, "last30": 2 }
  },
  "daily": [
    { "date": "2026-06-13", "subscriptions": 0 },
    { "date": "2026-06-14", "subscriptions": 2 }
    // … 30 jours, du plus ancien au plus récent
  ]
}
```

```bash
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  http://hydrogen.dev.com/admin/newsletter/stats
```

---

### `GET /admin/newsletter/export`

Extraction **CSV** téléchargeable (`text/csv`, `Content-Disposition: attachment`) de l'intégralité des inscrits — parcours keyset interne, pas de pagination côté client. Import tableur / outil d'e-mailing externe. Mêmes colonnes que le listing (donc **aucun** hash de token).

**Query params**

| Param | Valeurs | Défaut | Notes |
|---|---|---|---|
| `status` | `pending` \| `confirmed` \| `unsubscribed` | _tous_ | valeur invalide = `400` |

Colonnes (en-tête) : `id, email, status, locale, source, userId, ipAddress, userAgent, confirmedAt, unsubscribedAt, createdAt, updatedAt`.

**Erreurs**

| Status | Body | Sens |
|---|---|---|
| `400` | `{ "error": "Query parameter 'status' must be one of: pending, confirmed, unsubscribed." }` | statut inconnu |
| `403` | `{ "error": "..." }` | auth KO |

```bash
# Export de la liste de diffusion confirmée vers un fichier
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/newsletter/export?status=confirmed" -o newsletter-confirmed.csv
```

---

### `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 des marques (`/admin/brands` — CRUD)

À l'inverse des pays (où Meili EST la source de vérité), les **marques** vivent dans **MySQL** : la base `catalogue_swap` (référencée « catalog » dans l'env, `CATALOG_DB_*`) et sa famille de tables `brand` / `brand_provider_type` / `provider_type` / `brand_link` / `brand_link_rank`. MySQL fait **foi**.

Chaque mutation est **reprojetée** dans un index Meili **séparé** : `brands_v2` (`MEILISEARCH_BRANDS_V2_INDEX`). L'index legacy `brands` (lu par `GET /api/brands`) **n'est jamais touché** — le document `brands_v2` adopte une nouvelle forme (voir ci-dessous).

Réservé admin (`ADMIN_API_TOKEN`), **JSON plat brut**. Clé primaire = `id` entier auto-incrémenté de `catalogue_swap.brand`.

**Reprojection fail-soft** : MySQL faisant foi, une panne Meili ne fait **jamais** échouer l'écriture SQL déjà commitée. Le bloc `meili` de la réponse porte le signal — `{ "taskUid": <int> }` en cas de succès (écriture Meili asynchrone), `{ "error": "<message Meili>" }` sinon. En cas d'`error`, rejouer `php bin/brands-v2-reindex.php` pour resynchroniser l'index.

**Forme du document `brands_v2`** (produit par la requête d'indexation, forme mono-marque) :

```json
{
  "id": 1,
  "code": "fram",
  "name": "FRAM",
  "status": "active",
  "margin": { "value": 1.32, "unit": "%", "brand": 5 },
  "types": [ { "name": "package", "position": 2 } ],
  "links": { "global": "https://…", "media": "https://…" },
  "icon": {
    "light":      "http://hydrogen.dev.com/assets/images/brands/fram/icon-light.svg?v=1784374157",
    "dark":       null,
    "monochrome": null
  },
  "logo": {
    "light":      null,
    "dark":       null,
    "monochrome": "http://hydrogen.dev.com/assets/images/brands/fram/logo-monochrome.svg?v=1784374515"
  },
  "cookie_timing": 42300,
  "created_at": 1759571924,
  "updated_at": 1772920331
}
```

Les blocs `icon` / `logo` sont chacun un objet `{ light, dark, monochrome }` portant l'URL de la variante tonale (ou `null` quand elle est vide). **L'index `brands_v2` ne stocke QUE le chemin relatif** (`assets/images/brands/…`, sans host, sans slash initial) → le document est agnostique de l'environnement. `APP_URL` (source de vérité unique de l'origine) est préfixée **à la restitution JSON** : les réponses admin ci-dessus renvoient donc l'URL absolue, tandis que le host n'est jamais gelé dans l'index. `BRAND_IMAGE_PUBLIC_URL` reste une base relative nue (host éventuel retiré par sécurité). **Ils n'ont pas de colonne DB** — le système de fichiers fait foi pour leur présence ; chaque URL embarque un cache-buster `?v=<mtime>` (les uploads écrasent le fichier en place). Voir la section artwork ci-dessous.

**Corps d'écriture** (camelCase, aplati) → colonnes :

| Champ entrée | Colonne / relation | Règles |
|---|---|---|
| `frontId` | `brand.front_id` | 6 caractères alphanumériques ; **requis** en création |
| `name` | `brand.name` | non vide, ≤ 45 ; **requis** en création |
| `code` | `brand.code` | ≤ 45, nullable |
| `status` | `brand.status` | `"active"`/`"inactive"` ou `1`/`0` (défaut `0` en création) |
| `typesMask` | `brand.types` | entier ≥ 0 (colonne bitmask legacy, défaut `0`) |
| `brandMargin` | `brand.brand_margin` | nombre ≥ 0 |
| `margin` | `brand.margin` | nombre ou `null` |
| `marginUnit` | `brand.margin_unit` | ≤ 5 caractères, nullable |
| `cookieTiming` | `brand.cookie_timing` | entier ≥ 0 |
| `types` | `brand_provider_type` | `[{ providerTypeId, position } …]` ; `providerTypeId` doit exister dans `provider_type` |
| `links` | `brand_link` | `{ "<rankName>": "<url>" }` ; `rankName` ∈ `brand_link_rank.name` (`global`, `media`) |

En **update** (`PUT`), seules les clés présentes dans le corps sont écrites ; `types` / `links` ne sont **remplacées intégralement** que si leur clé est présente (les omettre laisse la relation intacte).

### `GET /admin/brands`

Dump complet du catalogue (borné ~100 lignes, non paginé), chaque entrée est le document `brands_v2` reconstruit **live** depuis MySQL.

| Status | Body |
|---|---|
| `200` | `{ "data": [ <document>, … ], "total": <int> }` |

### `GET /admin/brands/{id}`

Le document `brands_v2` d'une marque, reconstruit depuis MySQL, **enrichi d'un bloc `editorial`** absent du document indexé : `editorial` est un objet `{ "<locale>": { description?, shortDescription? }, … }` rassemblant **toutes les traductions stockées** (contenu localisable hors Meili, voir la section éditoriale ci-dessous). Objet **vide** `{}` si la marque n'a pas de `code` ou n'a aucune traduction.

| Status | Body |
|---|---|
| `200` | `<document brand>` + `"editorial": { "<locale>": { … } }` |
| `404` | `{ "error": "Brand not found." }` |

### `POST /admin/brands`

**Création**. `frontId` + `name` requis.

| Status | Body | Sens |
|---|---|---|
| `201` | `{ "status":"created", "id":<int>, "meili":{ "taskUid":<int|null> } }` | créée + reprojetée |
| `400` | `{ "error": "Request body must be a JSON object." }` | JSON invalide |
| `422` | `{ "errors": ["…"] }` | validation |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"frontId":"a1b2c3","code":"acme","name":"Acme","status":"active","types":[{"providerTypeId":1,"position":5}],"links":{"global":"https://acme.example/"}}' \
  "http://hydrogen.dev.com/admin/brands"
```

### `PUT /admin/brands/{id}`

**Update** partiel-tolérant (voir règles ci-dessus). `404` si la marque n'existe pas.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"updated", "id":<int>, "meili":{ … } }` | modifiée + reprojetée |
| `400` | `{ "error": "Request body must be a JSON object." }` | JSON invalide |
| `404` | `{ "error": "Brand not found." }` | id inconnu |
| `422` | `{ "errors": ["…"] }` | validation |

### `DELETE /admin/brands/{id}`

**Suppression** (marque + ses `brand_provider_type` + `brand_link`) puis retrait du document `brands_v2`. `404` si absente.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"deleted", "id":<int>, "meili":{ "taskUid":<int|null> } }` | supprimée + dé-indexée |
| `404` | `{ "error": "Brand not found." }` | id inconnu |

**Bootstrap / resync** : `php bin/brands-v2-reindex.php` applique les settings de l'index `brands_v2` (filterable `id`/`code`/`status`/`types.name`, sortable `types.position`, searchable `name`/`code`) puis y pousse tout le catalogue depuis MySQL. Idempotent.

### Artwork marque (`icon` / `logo` × `light` / `dark` / `monochrome`)

Deux slots administrables par marque — `icon` (carré) et `logo` (large) — déclinés chacun en **3 variantes tonales** : `light` (mark clair, pour fonds sombres), `dark` (mark sombre, pour fonds clairs) et `monochrome` (aplat mono-couleur). Soit **6 fichiers** possibles par marque. L'artwork est **SVG uniquement** (vectoriel, net à toute taille) : chaque upload est **sanitisé** (`enshrined/svg-sanitize` — `<script>`, handlers `on*`, références distantes/XXE strippés) puis stocké **verbatim** sous le répertoire **`code`** de la marque : `assets/images/brands/{code}/{type}-{mode}.svg` (p. ex. `icon-light.svg`, `logo-monochrome.svg`). L'artwork étant **keyé par `code`**, une marque **sans `code`** est refusée (`409`). Après chaque écriture la marque est **reprojetée** dans `brands_v2` (blocs `icon`/`logo` rafraîchis) — même contrat `meili` fail-soft que le CRUD.

Format accepté : **SVG uniquement** (`image/svg+xml`). Un payload non parseable en SVG est rejeté en `422 brandImage.invalidSvg`.

Les URLs **renvoyées dans le JSON** (réponse d'upload et documents admin) sont **absolues, ancrées sur `APP_URL`** : `APP_URL` (source de vérité unique de l'origine) est préfixée à la restitution. En revanche l'**index `brands_v2` ne stocke QUE le chemin relatif** (`assets/images/brands/…`) — aucun host n'y est figé, l'index reste donc valable sur tous les environnements. `BRAND_IMAGE_PUBLIC_URL` doit rester une base relative nue (host et slash initial retirés par sécurité) ; ne pas y dupliquer le host.

#### `POST /admin/brands/{id}/images/{type}/{mode}`

Pose / remplace une variante. `{type}` ∈ `icon` | `logo` ; `{mode}` ∈ `light` | `dark` | `monochrome`. Corps : `multipart/form-data`, champ **`image`**.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"set"\|"replace", "id":<int>, "type":"icon"\|"logo", "mode":"light"\|"dark"\|"monochrome", "url":"<url absolue ancrée APP_URL>", "meili":{ "taskUid":<int\|null> } }` | posé (`set`) ou remplacé (`replace`) + reprojeté |
| `404` | `{ "error": "Unknown image type (expected icon or logo)." }` / `{ "error": "Unknown image mode (expected light, dark or monochrome)." }` / `{ "error": "Brand not found." }` | type, mode ou marque inconnus |
| `409` | `{ "error": "Brand has no code; images are keyed by code." }` | marque sans `code` |
| `422` | `{ "error":"…", "code":"brandImage.empty"\|"brandImage.invalidSvg" }` | champ `image` absent / vide / non parseable en SVG |
| `413` | `{ "error":"…", "code":"brandImage.tooLarge" }` | dépasse `BRAND_IMAGE_MAX_UPLOAD_BYTES` |
| `400` | `{ "error":"…", "code":"brandImage.uploadFailed" }` | erreur upload PSR-7 |
| `500` | `{ "error":"…", "code":"brandImage.storageWriteFailed" }` | écriture disque |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -F 'image=@fram-logo.svg;type=image/svg+xml' \
  "http://hydrogen.dev.com/admin/brands/1/images/logo/monochrome"
```

#### `DELETE /admin/brands/{id}/images/{type}/{mode}`

Retire une variante (idempotent). L'URL correspondante dans le bloc `icon`/`logo` du document redevient `null` après reprojection.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"deleted"\|"absent", "id":<int>, "type":"icon"\|"logo", "mode":"light"\|"dark"\|"monochrome", "meili":{ "taskUid":<int\|null> } }` | supprimé (`deleted`) ou déjà absent (`absent`) |
| `404` | `{ "error": "Unknown image type (expected icon or logo)." }` / `{ "error": "Unknown image mode (expected light, dark or monochrome)." }` / `{ "error": "Brand not found." }` | type, mode ou marque inconnus |
| `409` | `{ "error": "Brand has no code; images are keyed by code." }` | marque sans `code` |

### Contenu éditorial localisable (`description` / `shortDescription`)

Deux champs texte **traduisibles** par marque — `description` (corps long, Markdown libre) et `shortDescription` (résumé court) — gérés par des **fichiers de localisation PHP**, délibérément tenus **HORS** de l'index `brands_v2` (le catalogue MySQL fait foi pour la ligne marque, mais sa copie marketing longue vit ici comme texte localisable). Aucune reprojection Meili n'est déclenchée par ces endpoints ; le contenu est exposé côté admin uniquement (via le bloc `editorial` du `GET /admin/brands/{id}`), **jamais** sur l'API publique `GET /api/brands`.

Stockage : `resources/lang/{locale}/brands/{code}/content.php` (un fichier `return [...]` par marque et par locale), **keyé par le `code`** de la marque — une marque **sans `code`** est refusée (`409`), comme l'artwork. Locale **exacte, sans fallback** : un éditeur gère une traduction précise, un GET renvoie ce qui est stocké pour CETTE locale (ou `null`). Écriture **atomique** (temp + rename). `{locale}` doit être une locale supportée (répertoire sous `resources/lang/`, p. ex. `fr-FR`, `en-US`).

Limites : `description` ≤ 65 536 octets, `shortDescription` ≤ 1 024 octets. Les champs sont **trimmés** et les vides **retirés** ; un champ non-string est rejeté en `422`. En entrée le PUT accepte aussi `short_description` (snake_case) comme alias de `shortDescription`.

#### `GET /admin/brands/{id}/editorial/{locale}`

Lit les champs éditoriaux stockés pour **exactement** cette locale (pas de fallback).

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "id":<int>, "code":"<code>", "locale":"<locale>", "content": { "description":"…", "shortDescription":"…" } \| null }` | `content` = `null` si rien pour cette locale |
| `404` | `{ "error": "Brand not found." }` | id inconnu |
| `409` | `{ "error": "Brand has no code; editorial content is keyed by code." }` | marque sans `code` |
| `422` | `{ "error": "Unsupported locale." }` | locale non supportée |

#### `PUT /admin/brands/{id}/editorial/{locale}`

Crée ou **remplace intégralement** (pas de merge) les champs éditoriaux pour cette locale. Corps JSON : `{ "description"?, "shortDescription"? }`. La réponse renvoie la forme **normalisée** effectivement stockée (`content` = `null` si les deux champs sont vides après normalisation).

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "id":<int>, "code":"<code>", "locale":"<locale>", "content": { … } \| null }` | écrit |
| `400` | `{ "error": "Request body must be a JSON object." }` | JSON invalide |
| `404` | `{ "error": "Brand not found." }` | id inconnu |
| `409` | `{ "error": "Brand has no code; editorial content is keyed by code." }` | marque sans `code` |
| `422` | `{ "error": "…" }` | locale non supportée / champ non-string / champ trop long |

```bash
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"description":"FRAM, spécialiste du voyage organisé depuis 1949.","shortDescription":"Voyages organisés."}' \
  "http://hydrogen.dev.com/admin/brands/1/editorial/fr-FR"
```

#### `DELETE /admin/brands/{id}/editorial/{locale}`

Retire la traduction pour cette locale. `404` si aucun contenu n'existe pour la locale.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "id":<int>, "code":"<code>", "locale":"<locale>", "deleted":true }` | supprimé |
| `404` | `{ "error": "Brand not found." }` / `{ "error": "No editorial content for this locale." }` | id inconnu ou pas de contenu |
| `409` | `{ "error": "Brand has no code; editorial content is keyed by code." }` | marque sans `code` |
| `422` | `{ "error": "Unsupported locale." }` | locale non supportée |

---

## Référentiel des types de prestataire (`/admin/provider-types` — CRUD)

Référentiel `catalogue_swap.provider_type` : les types (`package`, `accommodations`, `restaurant`, …) auxquels les marques sont rattachées via `brand_provider_type`. Source de vérité **MySQL**, réservé admin (`ADMIN_API_TOKEN`), **JSON plat brut**. PK = `id` entier auto-incrémenté.

**Champs** : `name` (varchar ≤ 45, **unique** — garde applicative, c'est l'identifiant logique du type dans `brands_v2`), `position` (tinyint 0..255, ordre d'affichage), `mask` (bitmask legacy, int ≥ 0). Sortie enrichie de `usedBy` = nombre de marques rattachées.

**Deux couplages avec les marques** :

- **Rename** (`name` modifié) : le `name` est **dénormalisé** dans chaque document `brands_v2` (`types[].name`). L'`UPDATE` reprojette donc toutes les marques concernées et remonte le résultat dans `meili: { reprojected, failed }` — **fail-soft** (MySQL fait foi ; en cas de `failed > 0`, rejouer `bin/brands-v2-reindex.php`). Un changement de `position`/`mask` ne reprojette rien (hors document marque).
- **Delete gardé** : la FK `bpt_provider_type` interdit de supprimer un type encore rattaché. L'action vérifie l'usage en amont et renvoie `409` (+ `usedBy`) plutôt qu'une erreur SQL brute — détacher d'abord les marques via `PUT /admin/brands/{id}`.

### `GET /admin/provider-types`

Dump complet du référentiel (ordonné par `position`), chaque entrée avec son `usedBy`.

| Status | Body |
|---|---|
| `200` | `{ "data": [ { id, name, position, mask, createdAt, usedBy }, … ], "total": <int> }` |

### `GET /admin/provider-types/{id}`

| Status | Body |
|---|---|
| `200` | `{ id, name, position, mask, createdAt, usedBy }` |
| `404` | `{ "error": "Provider type not found." }` |

### `POST /admin/provider-types`

**Création**. `name` requis (unique) ; `position` / `mask` optionnels (défaut 0). Pas de reprojection (un type neuf n'est référencé par aucune marque).

| Status | Body | Sens |
|---|---|---|
| `201` | `{ "status":"created", "id":<int> }` | créé |
| `400` | `{ "error": "Request body must be a JSON object." }` | JSON invalide |
| `409` | `{ "error": "Provider type name already exists." }` | nom déjà pris |
| `422` | `{ "errors": ["…"] }` | validation |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"name":"experience","position":12}' \
  "http://hydrogen.dev.com/admin/provider-types"
```

### `PUT /admin/provider-types/{id}`

**Update** partiel-tolérant. Un rename effectif reprojette les marques rattachées.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"updated", "id":<int>, "meili":{ "reprojected":<int>, "failed":<int> } \| null }` | modifié (`meili` non-null seulement si le nom a changé) |
| `400` | `{ "error": "Request body must be a JSON object." }` | JSON invalide |
| `404` | `{ "error": "Provider type not found." }` | id inconnu |
| `409` | `{ "error": "Provider type name already exists." }` | nom déjà pris |
| `422` | `{ "errors": ["…"] }` | validation |

### `DELETE /admin/provider-types/{id}`

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"deleted", "id":<int> }` | supprimé |
| `404` | `{ "error": "Provider type not found." }` | id inconnu |
| `409` | `{ "error": "Provider type is still used by N brand(s).", "usedBy": N }` | encore rattaché |

---

## Gestion des badges (`/admin/badges` — CRUD + assignation)

Catalogue de gamification `badge` + `badge_tier` (MySQL, source de vérité), réservé admin (`ADMIN_API_TOKEN`), **JSON plat brut**. Le `slug` est l'**identité publique** (aussi la clé i18n dans `resources/lang/<locale>/badges.php` et le chemin d'icône) — **immuable** après création. Le `metric` est une chaîne libre (convention `<domaine>.<événement>`, ex. `media.uploaded`) rapprochée des seuils au moment de l'attribution automatique ; ce n'est **pas** une FK.

**Deux surfaces** :

- **Catalogue** (`/admin/badges`) — CRUD, auth `ADMIN_API_TOKEN`. Un badge + ses paliers sont écrits dans **une transaction** (jamais de badge à moitié configuré).
- **Assignation** (`/admin/users/{hex}/badges/*`) — attribution/révocation manuelle par un opérateur, auth **HYBRIDE** (`ADMIN_API_TOKEN` OU jeton staff nominatif), journalisée dans `hxa_bo.user_action_log` (actions `user.badge.assign` / `user.badge.revoke`).

**Type & paliers** : `type` ∈ {`unique`, `level`}. Un badge `unique` a **exactement un** palier au niveau 1 ; un badge `level` a 1..5 paliers (niveaux distincts, 1..5). Chaque palier = `{ level, threshold (≥0), xpReward (≥0) }`.

**Suppression gardée** : `DELETE /admin/badges/{slug}` est **refusé en 409** si des utilisateurs détiennent le badge (la FK `user_badge` cascaderait et effacerait l'historique). Pour retirer un badge de la circulation, le **désactiver** (`PUT … isActive:false`) : il reste lisible pour les possessions historiques mais n'est plus attribué automatiquement.

### `GET /admin/badges`

Dump complet (actifs **et** inactifs, ordonné par `position, id`). Le compteur de détenteurs n'est **pas** calculé ici (coût O(n)) — voir le détail.

| Status | Body |
|---|---|
| `200` | `{ "total": <int>, "items": [ { id, slug, type, metric, categoryId:int\|null, category:slug\|null, position, isActive, maxLevel, maxThreshold, tiers:[{level,threshold,xpReward}], translations, createdAt, updatedAt }, … ] }` |

Chaque item porte `translations` : la carte `{ "<locale>": { "label", "description":string\|null } }` de **toutes** les traductions stockées pour ce badge (lues depuis `resources/lang/<locale>/badges.php`), `{}` si aucune. Les catalogues ne sont lus qu'**une fois par locale** pour toute la liste. `category` est le slug de la catégorie de rattachement (`null` si non catégorisé), `categoryId` son id interne ; les slugs sont résolus en **un seul** aller-retour pour toute la liste.

### `GET /admin/badges/{slug}`

Détail enrichi du compteur `holders` (détenteurs, tous niveaux) et de la carte `translations`.

| Status | Body |
|---|---|
| `200` | `{ id, slug, type, metric, categoryId:int\|null, category:slug\|null, position, isActive, maxLevel, maxThreshold, tiers:[…], translations:{ "<locale>":{label,description} }, holders:<int>, createdAt, updatedAt }` |
| `404` | `{ "error": "Badge not found." }` |

### `POST /admin/badges`

**Création** (badge + paliers en une transaction).

Body : `{ "slug":"shutterbug", "type":"level", "metric":"media.uploaded", "category":"creation", "position":10, "isActive":true, "tiers":[{"level":1,"threshold":10,"xpReward":50}, …], "translations":{ "fr-FR":{"label":"…","description":"…"|null} } }` (`category`/`position`/`isActive`/`translations` optionnels, défaut `null`/`0`/`true`/`{}`).

Le champ **optionnel** `category` est un **slug** de `badge_category` (ou `null` pour non catégorisé) ; un slug inconnu est refusé en `422`. Le champ **optionnel** `translations` écrit d'emblée le `label`/`description` localisés dans `resources/lang/<locale>/badges.php`. Chaque locale est **validée avant** la transaction DB (locale supportée, `label` non vide ≤ 160 o, `description` ≤ 2048 o, vide → `null`) ; les fichiers sont écrits **après** le commit du badge (mêmes garanties que l'endpoint dédié). La réponse renvoie la carte `translations` complète.

| Status | Body | Sens |
|---|---|---|
| `201` | `{ <badge>, "holders":0, "translations":{…} }` | créé |
| `400` | `{ "error": "<validation>" }` | slug/type/metric/tiers/category (forme)/translations (forme) invalides |
| `409` | `{ "error": "Badge '<slug>' already exists." }` | slug déjà pris |
| `422` | `{ "error": "translations['<locale>']: <raison>" }` / `{ "error": "Unknown category '<slug>'." }` | locale non supportée, label vide/trop long, ou catégorie inconnue |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"slug":"shutterbug","type":"level","metric":"media.uploaded","tiers":[{"level":1,"threshold":10,"xpReward":50},{"level":2,"threshold":50,"xpReward":150}],"translations":{"fr-FR":{"label":"Photographe","description":"Publie des médias."}}}' \
  "http://hydrogen.dev.com/admin/badges"
```

### `PUT /admin/badges/{slug}`

**Remplacement complet** des métadonnées + du jeu de paliers (delete + re-insert en une transaction). Le `slug` reste inchangé. Body = même forme que `POST` sans `slug`.

`category` suit la sémantique **remplacement complet** : un `category` absent ou `null` **retire** le rattachement (un slug inconnu reste refusé en `422`). Le champ optionnel `translations` est un **upsert** : chaque locale fournie est (ré)écrite ; les locales **non envoyées restent intactes** (pour en retirer une, utiliser `DELETE /admin/badges/{slug}/translations/{locale}`). Même validation pré-transaction que `POST`.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ <badge>, "holders":<int>, "translations":{…} }` | modifié |
| `400` | `{ "error": "<validation>" }` | corps invalide |
| `404` | `{ "error": "Badge not found." }` | slug inconnu |
| `422` | `{ "error": "translations['<locale>']: <raison>" }` | locale non supportée, label vide, champ trop long |

### `DELETE /admin/badges/{slug}`

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "slug":"<slug>" }` | supprimé (aucun détenteur) |
| `404` | `{ "error": "Badge not found." }` | slug inconnu |
| `409` | `{ "error": "Badge '<slug>' still has <n> holder(s); deactivate it instead." }` | encore détenu |

### Icônes SVG (`/admin/badges/{slug}/icons/{slot}`)

Upload / suppression des fichiers d'icônes que `BadgeIconUrlBuilder` expose sur chaque ressource badge. `{slot}` vaut soit un **palier** (`1`..`5`), soit `locked` (silhouette grisée partagée). Le fichier est un **SVG assaini** (scripts, gestionnaires d'événements et références distantes retirés) écrit **verbatim** sous `<racine>/<slug>/<slot>.svg` — exactement le chemin lu par le front. La racine par défaut (`BADGE_ICON_STORAGE_PATH`) est le répertoire web-servi `public/assets/images/badges`, donc l'upload est immédiatement accessible. L'URL renvoyée est construite sur le **modèle des marques** : base relative `BADGE_ICON_PUBLIC_URL` + `APP_URL` préfixé au read (aucun host figé). Auth `ADMIN_API_TOKEN`, **JSON plat**.

Un palier au-delà du `maxLevel` du badge est refusé en `422` (aucun palier à illustrer). Le slot `locked` est toujours accepté.

#### `POST /admin/badges/{slug}/icons/{slot}`

Corps : `multipart/form-data`, champ **`icon`** (charge `image/svg+xml`).

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"set"\|"replace", "slug":"<slug>", "slot":"level <n>"\|"locked", "level":<int\|null>, "url":"<url publique>" }` | écrit (nouveau ou remplacé) |
| `404` | `{ "error": "Badge not found." \| "Unknown icon slot (expected locked or 1..5)." }` | slug/slot inconnu |
| `422` | `{ "error":"...", "code":"badgeIcon.empty"\|"badgeIcon.invalidSvg" }` | champ manquant, SVG invalide, ou palier > maxLevel |
| `413` | `{ "error":"...", "code":"badgeIcon.tooLarge" }` | dépasse `BADGE_ICON_MAX_UPLOAD_BYTES` |
| `400` | `{ "error":"...", "code":"badgeIcon.uploadFailed" }` | erreur d'upload PSR-7 |
| `500` | `{ "error":"...", "code":"badgeIcon.storageWriteFailed" }` | échec d'écriture disque |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -F 'icon=@shutterbug-3.svg;type=image/svg+xml' \
  "http://hydrogen.dev.com/admin/badges/shutterbug/icons/3"
```

#### `DELETE /admin/badges/{slug}/icons/{slot}`

Idempotent (`status:"absent"` si le fichier n'existait pas). La ligne `badge` n'est pas touchée (les icônes sont dérivées du `slug`).

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"deleted"\|"absent", "slug":"<slug>", "slot":"level <n>"\|"locked", "level":<int\|null> }` | supprimé (ou no-op) |
| `404` | `{ "error": "Badge not found." \| "Unknown icon slot (expected locked or 1..5)." }` | slug/slot inconnu |

### Textes traduisibles (`/admin/badges/{slug}/translations/{locale}`)

Édition du **`label` + `description` localisés** d'un badge (les champs que le read-path résout via le `Translator`, clés `badges.<slug>.{label,description}`). Contrairement aux icônes/au catalogue MySQL, ces textes vivent dans le catalogue **plat** `resources/lang/<locale>/badges.php` (clé = slug). Un write relit tout le catalogue, modifie l'entrée du slug et **réécrit le fichier atomiquement** (les autres slugs sont préservés ; les commentaires du fichier ne le sont pas). Auth `ADMIN_API_TOKEN`, **JSON plat**.

**Locale exacte, pas de fallback** : on gère UNE traduction à la fois. `{locale}` doit être supportée (dossier présent sous `resources/lang/`). Le badge peut être **inactif** (les possessions historiques restent lisibles). La prise en compte côté lecture publique intervient à la requête suivante (catalogues rechargés par process).

#### `GET /admin/badges/{slug}/translations/{locale}`

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "slug":"<slug>", "locale":"<locale>", "label":string\|null, "description":string\|null }` | `null` si aucune entrée pour ce locale |
| `404` | `{ "error": "Badge not found." }` | slug inconnu |
| `422` | `{ "error": "Unsupported locale." }` | locale non supportée |

#### `PUT /admin/badges/{slug}/translations/{locale}`

Corps : `{ "label": "<requis, non vide>", "description": "<optionnel>|null" }`. Un `description` vide/absent n'en stocke aucun (lu `null`). `label` ≤ 160 o, `description` ≤ 2048 o (après trim).

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "slug", "locale", "created":false, "label", "description" }` | entrée mise à jour |
| `201` | `{ …, "created":true, … }` | entrée créée |
| `400` | `{ "error": "<validation>" }` | corps invalide / `label` manquant |
| `404` | `{ "error": "Badge not found." }` | slug inconnu |
| `422` | `{ "error": "..." }` | locale non supportée / label vide / champ trop volumineux |

```bash
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"label":"Photographe","description":"Récompense votre régularité."}' \
  "http://hydrogen.dev.com/admin/badges/shutterbug/translations/fr-FR"
```

#### `DELETE /admin/badges/{slug}/translations/{locale}`

Retire l'entrée du slug pour ce locale (le catalogue est réécrit avec les slugs restants).

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "slug", "locale", "deleted":true }` | traduction supprimée |
| `404` | `{ "error": "Badge not found." \| "No translation for this locale." }` | slug inconnu ou pas de traduction |
| `422` | `{ "error": "Unsupported locale." }` | locale non supportée |

## Catégories de badges (`/admin/badge-categories` — CRUD)

Référentiel de **regroupement** des badges (un badge → **au plus une** catégorie, via `badge.category_id`). Source de vérité = MySQL `badge_category`. Le `slug` est l'identité publique **et** la clé i18n (`badge_categories.<slug>`) — **immuable** après création. Les **labels localisés** ne sont pas en base : ils vivent dans le catalogue **plat** `resources/lang/<locale>/badge_categories.php` (clé = slug), édités one-locale-no-fallback et réécrits atomiquement (comme les labels de badges). Auth `ADMIN_API_TOKEN`, **JSON plat**.

Côté public, `GET /api/badges` porte un bloc `category` (`{slug,label}` ou `null`) sur chaque ressource, et `?groupBy=category` renvoie des segments contigus décrits par `meta.groups`.

### `GET /admin/badge-categories`

Référentiel complet, ordonné par `(position, id)`. Chaque item porte ses labels sous `translations` (lus **une fois par locale** pour toute la liste). Les compteurs de badges ne sont **pas** calculés ici — voir le détail.

| Status | Body |
|---|---|
| `200` | `{ "total":<int>, "items":[ { id:int, slug, position, translations:{ "<locale>":"<label>" }, createdAt, updatedAt }, … ] }` |

### `GET /admin/badge-categories/{slug}`

Détail enrichi de `badgeCount` (badges rattachés) et de la carte `translations`.

| Status | Body |
|---|---|
| `200` | `{ id, slug, position, translations:{ "<locale>":"<label>" }, badgeCount:<int>, createdAt, updatedAt }` |
| `404` | `{ "error": "Badge category not found." }` |

### `POST /admin/badge-categories`

**Création**. Body : `{ "slug":"onboarding", "position":10, "translations":{ "fr-FR":{"label":"Prise en main"} } }` (`position`/`translations` optionnels, défaut `0`/`{}`).

Le champ optionnel `translations` (label seul, ≤ 120 o, non vide) est **validé avant** l'écriture DB puis persisté **après**. `slug` : `^[a-z0-9-]{1,64}$`.

| Status | Body | Sens |
|---|---|---|
| `201` | `{ <category>, "badgeCount":0 }` | créé |
| `400` | `{ "error": "<validation>" }` | slug/position/translations (forme) invalides |
| `409` | `{ "error": "Badge category '<slug>' already exists." }` | slug déjà pris |
| `422` | `{ "error": "translations['<locale>']: <raison>" }` | locale non supportée / label vide/trop long |

```bash
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"slug":"onboarding","position":10,"translations":{"fr-FR":{"label":"Prise en main"},"en-US":{"label":"Getting started"}}}' \
  "http://hydrogen.dev.com/admin/badge-categories"
```

### `PUT /admin/badge-categories/{slug}`

Met à jour `position` (le `slug` reste inchangé). `translations` est un **upsert** : chaque locale fournie est (ré)écrite ; les locales non envoyées restent intactes.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ <category>, "badgeCount":<int> }` | modifié |
| `400` | `{ "error": "<validation>" }` | corps invalide |
| `404` | `{ "error": "Badge category not found." }` | slug inconnu |
| `422` | `{ "error": "translations['<locale>']: <raison>" }` | locale non supportée / label vide/trop long |

### `DELETE /admin/badge-categories/{slug}`

Supprime une catégorie **vide**. Une catégorie encore rattachée à ≥ 1 badge est refusée en `409` (réassigner les badges d'abord). Les labels localisés sont **purgés** de toutes les locales au succès.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"deleted", "slug":"<slug>" }` | supprimé (aucun badge rattaché) |
| `404` | `{ "error": "Badge category not found." }` | slug inconnu |
| `409` | `{ "error": "Badge category '<slug>' still holds <n> badge(s); reassign them first." }` | encore utilisée |

### `GET /admin/users/{hex}/badges`

Badges détenus par l'utilisateur (inactifs inclus), ordonnés par catalogue. Auth **hybride**.

| Status | Body |
|---|---|
| `200` | `{ "userId":"<hex>", "total":<int>, "items":[ { slug, level, maxLevel, isActive, earnedAt, updatedAt }, … ] }` |
| `404` | `{ "error": "User not found." }` |

### `PUT /admin/users/{hex}/badges/{slug}`

**Attribution manuelle** (autorité admin). Fixe le niveau **exact** (hausse OU baisse). **Aucun XP** n'est accordé (l'octroi manuel n'est pas de la progression gagnée). Une **hausse** notifie l'utilisateur (une notif `badge.earned` par palier franchi) ; un niveau égal/inférieur est silencieux. Le badge n'a **pas** besoin d'être actif. Auth **hybride**, journalisé.

Body : `{ "level": 1..maxLevel, "reason": "note optionnelle" }`.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "userId":"<hex>", "slug":"<slug>", "level":<int>, "previousLevel":<int>, "transition":"grant"\|"raise"\|"lower"\|"none" }` | appliqué (idempotent si `none`) |
| `400` | `{ "error": "<validation>" }` | `level` manquant/hors 1..5 |
| `404` | `{ "error": "User not found." \| "Badge not found." }` | cible inconnue |
| `422` | `{ "error": "level exceeds this badge's maxLevel (<n>)." }` | niveau > paliers du badge |

```bash
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"level":3,"reason":"gagnant concours photo"}' \
  "http://hydrogen.dev.com/admin/users/<hex>/badges/shutterbug"
```

### `DELETE /admin/users/{hex}/badges/{slug}`

**Révocation** : retire entièrement la possession. **Aucune notification** (on n'annonce pas un retrait), aucun débit d'XP. Idempotent (`transition:"none"` si non détenu). `reason` optionnel via corps JSON. Auth **hybride**, journalisé.

| Status | Body | Sens |
|---|---|---|
| `200` | `{ "status":"ok", "userId":"<hex>", "slug":"<slug>", "previousLevel":<int>, "transition":"revoke"\|"none" }` | retiré (ou no-op) |
| `404` | `{ "error": "User not found." \| "Badge not found." }` | cible inconnue |

---

## 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"
```

---

## Contenu éditorial localisable des pays (`/admin/countries/{code}/…`)

Tout ce qui est **géographique** vit dans l'index Meili (source de vérité). En
revanche le **contenu éditorial traduisible** n'est **pas** indexé : il est
stocké dans des fichiers versionnés sous
`resources/lang/<locale>/countries/<code>/` :

- `description.md` — corps **Markdown** libre ;
- `content.php` — tableau **structuré** (`tagline`, `arguments`, `highlights`,
  `bestTimeToVisit` + `seasons`).

Ces endpoints admin (`ADMIN_API_TOKEN`, **JSON plat**) permettent de lire et
d'écrire ces champs **par locale exacte** (BCP-47 `fr-FR`, `en-US`, … — la
locale doit être supportée, c.-à-d. avoir un dossier sous `resources/lang/`).
Contrairement au rendu public (résolution locale → fallback → `null`), l'admin
cible **une seule locale sans fallback** : un GET renvoie ce qui est réellement
stocké pour CETTE traduction (ou `null`), jamais un fallback qui serait écrasé
au prochain PUT. Les écritures sont **atomiques** (fichier temporaire + rename)
et le `content` est **normalisé** avant persistance (clés malformées ignorées,
une saison n'est retenue que si elle porte une `key` stable) — le PUT renvoie
donc exactement la forme stockée. Le pays doit exister dans l'index Meili.

| Méthode | Chemin | Sens |
|---|---|---|
| `GET` | `/countries/{code}/description/{locale}` | Lit le Markdown de la locale (`description: null` si absent) |
| `PUT` | `/countries/{code}/description/{locale}` | Crée/remplace le Markdown (`201` créé, `200` mis à jour) |
| `DELETE` | `/countries/{code}/description/{locale}` | Retire la traduction (`404` si absente) |
| `GET` | `/countries/{code}/content/{locale}` | Lit le contenu structuré (`content: null` si absent) |
| `PUT` | `/countries/{code}/content/{locale}` | Crée/remplace le contenu (renvoie la forme normalisée) |
| `DELETE` | `/countries/{code}/content/{locale}` | Retire la traduction (`404` si absente) |

**Corps de `PUT …/description/{locale}`** : `{ "description": "<markdown>" }`
(string requise ; ≤ 64 Kio ; chaîne vide autorisée — utiliser `DELETE` pour
supprimer le fichier).

**Corps de `PUT …/content/{locale}`** : l'objet `content` lui-même —

```json
{
  "tagline": "De la lumière de la Méditerranée aux sommets des Alpes.",
  "arguments": ["…", "…"],
  "highlights": ["Paris et le Louvre", "Le Mont-Saint-Michel"],
  "bestTimeToVisit": {
    "summary": "La France se visite toute l'année…",
    "seasons": [
      { "key": "spring", "label": "Printemps", "months": "Mars – Mai",
        "weather": "…", "ambiance": "…", "recommended": true }
    ]
  }
}
```

Erreurs communes : `400` corps invalide ; `404` code pays inconnu (ou traduction
absente sur `DELETE`) ; `422` locale non supportée / `description` trop
volumineuse ; `503` Meili injoignable.

```bash
# Publier la description anglaise de la France
curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"description":"# France\n\nA short blurb."}' \
  "http://hydrogen.dev.com/admin/countries/fr/description/en-US"

# Relire le contenu structuré français
curl -s -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  "http://hydrogen.dev.com/admin/countries/fr/content/fr-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"
```

---

## Documents éditoriaux (`/admin/documents/…`)

CRUD des **contenus éditoriaux brut Markdown** servis par la surface publique
[`/api/documents`](api.md#documents-éditoriaux) (CGU, politiques, aide, …). Source
de vérité **MySQL** (base `content`), avec cache PHP OPcache write-through
reconstruit à chaque mutation. **JSON plat.** Le document est identifié par son
**slug** (kebab-case, immuable après création).

**Authentification NOMINATIVE** : ces routes acceptent le **token statique**
`ADMIN_API_TOKEN` **ou** un **token staff** nominatif
(`AdminOrStaffAuthenticationMiddleware`). Quand un opérateur staff écrit une
traduction (`PUT`), son `username` est estampillé comme `editor` dans le
**journal de révisions** (traçabilité légale) ; avec le token statique l'`editor`
est `null` (système).

**Versionnement légal** : chaque `PUT` d'une traduction **snapshote** le contenu
écrit dans `document_revision` **dans la même transaction** — l'historique de ce
qui a été en vigueur est append-only et sans trou. La `version` et l'`effectiveAt`
copiés dans la révision sont ceux **portés par le document au moment du PUT**
(patchés via `PATCH /admin/documents/{slug}`).

**Locale exacte sans fallback** : comme le contenu éditorial des pays, les
endpoints par locale ciblent **une seule locale** (BCP-47 supportée) sans
fallback — un `GET` renvoie ce qui est réellement stocké pour CETTE traduction.

| Méthode | Chemin | Sens |
|---|---|---|
| `GET` | `/documents` | Catalogue complet (drafts inclus) + couverture de traduction. `?category=` filtre (`/^[a-z0-9-]{1,32}$/`) |
| `POST` | `/documents` | Crée un slug (sans traduction). `201` |
| `GET` | `/documents/{slug}` | Aggregate complet (toutes locales, corps brut) |
| `PATCH` | `/documents/{slug}` | Patch méta (category/status/version/effectiveAt) |
| `DELETE` | `/documents/{slug}` | Supprime (cascade traductions + révisions). `204` |
| `GET` | `/documents/{slug}/translations/{locale}` | Une traduction (corps + méta), locale exacte |
| `PUT` | `/documents/{slug}/translations/{locale}` | Crée/remplace la traduction (`201` créé, `200` maj) + snapshot révision |
| `DELETE` | `/documents/{slug}/translations/{locale}` | Retire la traduction (révisions conservées). `204` |
| `GET` | `/documents/{slug}/translations/{locale}/revisions` | Historique append-only, keyset `?limit=` (1..100, def. 20) `?before=` |

**Corps de `POST /documents`** :

```json
{ "slug": "terms-of-service", "category": "legal", "status": "draft" }
```

- `slug` (string, requis) — kebab-case `/^[a-z0-9]+(?:-[a-z0-9]+)*$/`, 1..80 chars.
- `category` (string, optionnel) — `/^[a-z0-9-]{1,32}$/`.
- `status` (string, optionnel, défaut `draft`) — `draft` | `published`.

**Corps de `PATCH /documents/{slug}`** (partiel, seules les clés fournies sont touchées) :

```json
{ "status": "published", "version": "2024-11", "effectiveAt": "2024-11-01T00:00:00Z", "category": "legal" }
```

- `category` : string (`/^[a-z0-9-]{1,32}$/`) ou `null`.
- `status` : `draft` | `published`.
- `version` : string non vide (≤ 20 chars) ou `null`.
- `effectiveAt` : ISO-8601 (normalisé UTC) ou `null`.

**Corps de `PUT /documents/{slug}/translations/{locale}`** :

```json
{
  "title": "Conditions générales d'utilisation",
  "body": "# Conditions générales\n\n…markdown brut…",
  "metaTitle": "CGU — Example",
  "metaDescription": "Nos conditions d'utilisation."
}
```

- `title` (string non vide, requis, ≤ 255).
- `body` (string, requis) — Markdown brut.
- `metaTitle` (string, optionnel, ≤ 255 ; `""` → `null`).
- `metaDescription` (string, optionnel, ≤ 500 ; `""` → `null`).

Erreurs communes : `400` corps/slug invalide ; `404` slug/traduction inconnu ;
`409` slug déjà pris (POST) ; `422` `status` hors domaine, locale non supportée,
ou champ trop volumineux.

```bash
# Créer puis publier des CGU, écrire la traduction FR
curl -s -X POST -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"slug":"terms-of-service","category":"legal"}' \
  "http://hydrogen.dev.com/admin/documents"

curl -s -X PATCH -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status":"published","version":"2024-11","effectiveAt":"2024-11-01T00:00:00Z"}' \
  "http://hydrogen.dev.com/admin/documents/terms-of-service"

curl -s -X PUT -H "Authorization: Bearer $ADMIN_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"CGU","body":"# CGU\n\n…"}' \
  "http://hydrogen.dev.com/admin/documents/terms-of-service/translations/fr-FR"
```

---

## 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/actions` | admin | Relecture du journal nominatif (keyset, filtres) |
| `GET` | `/admin/staff/stats` | admin | Activité staff sur une fenêtre glissante |
| `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) |
| `POST` | `/admin/staff/{id}/reset-password` | admin | Réinitialise le mot de passe et révoque toutes les sessions |

**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é.

**`POST /admin/staff/{id}/reset-password`** — réinitialisation de credential distincte du champ `password` du `PUT` : (a) peut **générer** un mot de passe conforme à la politique quand aucun n'est fourni — renvoyé **une seule fois** en clair pour transmission — et (b) **révoque systématiquement toutes les sessions bearer** de la cible (déconnexion partout), ce qui en fait l'outil adapté à un credential perdu/compromis. Corps JSON optionnel `{ "password"?: string }` : présent et non vide → utilisé (vérifié contre la politique, `422` si trop faible) ; absent → mot de passe fort minté. Réponse `200` : `{ "staffId": <int>, "revokedSessions": <int>, "generatedPassword": "<clair>" | null }` (`generatedPassword` non-null uniquement en cas de génération). Erreurs : `400` corps invalide, `404` id inconnu, `422` mot de passe fourni trop faible.

### 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.

**`GET /admin/staff/actions`** — relecture du journal, keyset newest-first (`id DESC`, le PK BIGINT monotone sert de curseur chronologique). Filtres tous optionnels et indépendants : `staffId` (int ; `0`/absent = tous — les événements non authentifiés, `staffId` nul, ne sont visibles que sans filtre), `action` (label logique, ex. `staff.login`), `from`/`to` (bornes ISO-8601 inclusives sur `created_at`), `limit` (1..200, def 50), `before` (curseur id, rend les lignes strictement plus anciennes). Réponse `{ "items": [ { id, staffId, username, action, method, path, status, metadata, ipAddress, userAgent, createdAt }, … ], "nextCursor": <id> | null }` (`nextCursor` non-null quand la page est pleine).

**`GET /admin/staff/stats`** — digest d'activité sur une fenêtre glissante `?days=30` (bornée 1..366). Combine un headcount du roster (`staff`) et une agrégation du journal : total d'actions, opérateurs les plus actifs (top 10), volume par label d'action, et une série journalière dense (zéro-remplie). Réponse `{ "roster": { total, activeAdmins }, "window": { days, from, to, totalActions }, "byStaff": [ { staffId, username, count }, … ], "byAction": [ { action, count }, … ], "daily": [ { date, actions }, … ] }`. Lecture seule.

---

## Grand livre de valeurs (`/admin/ledger/*`, `/admin/currencies/*`)

Surface d'exploitation du **grand livre** : journal immuable de tout mouvement de valeur (expérience, points, argent, monnaies maison). Deux tables : `ledger_entry` (append-only, partitionnée par mois) et `user_balance` (soldes dénormalisés, lus en O(1)). Voir la section publique correspondante dans `docs/api.md` (`GET /api/users/me/transactions`).

**Principe non négociable** : rien n'est jamais modifié ni supprimé. Une annulation crée une **écriture inverse** liée à l'originale (`reversesId`), et bascule le statut de l'originale à `reversed`.

### Catalogue des monnaies

Les monnaies sont des **données**, pas du code : le back-office peut créer une monnaie plateforme (gemmes, crédits…) sans redéploiement. Chaque monnaie porte son **code** (identité inscrite sur chaque écriture), sa **famille** (`kind`), sa **précision** (`scale`, nb de décimales réellement utilisées — le stockage reste `DECIMAL(20,4)`), son **symbole**, son **état** et son **plancher de découvert**.

Trois monnaies sont **système** (`isSystem: true`) : `XP`, `POINTS`, `EUR`. Le code applicatif les référence nommément, donc elles ne peuvent être ni renommées, ni supprimées, ni désactivées.

| Endpoint | Effet |
|---|---|
| `GET /admin/currencies` | Catalogue complet (`?activeOnly=true` pour masquer les désactivées). `{items, total}` |
| `GET /admin/currencies/{code}` | Une monnaie · `404` si inconnue |
| `POST /admin/currencies` | Crée une monnaie · `201` |
| `PUT /admin/currencies/{code}` | Mise à jour **partielle** (clé absente = inchangée) |
| `DELETE /admin/currencies/{code}` | Supprime · `409` si système **ou** si des écritures existent |

**Corps de `POST /admin/currencies`** (JSON plat) :

```json
{
  "code": "GEMS",                 // requis, 2..12 car. [A-Z0-9_], immuable ensuite
  "kind": "points",               // requis : experience | points | money
  "labels": { "fr-FR": "Gemmes", "en-US": "Gems" },   // requis, ≥ 1 locale
  "scale": 0,                     // optionnel, 0..4 (défaut 0)
  "symbol": "✦",                  // optionnel, ≤ 8 car., null pour effacer
  "isActive": true,               // optionnel (défaut true)
  "overdraftFloor": "0"           // optionnel, ≤ 0 (défaut 0)
}
```

- `code` est **immuable** : il est inscrit sur chaque écriture et chaque solde ; le renommer orphelinerait l'historique (`422` si le corps le tente).
- `overdraftFloor` borne le négatif autorisé pour cette monnaie : `0` interdit tout découvert, `-50` autorise un solde jusqu'à −50.
- `scale` valide les montants à l'écriture : une monnaie à `scale: 0` refuse `1.5`.
- **Désactiver plutôt que supprimer** : `isActive: false` bloque toute nouvelle écriture tout en gardant l'historique lisible. C'est la seule option dès qu'une monnaie a servi.

### Consultation du journal

**`GET /admin/ledger/entries`** — vue d'investigation sur l'ensemble du journal, du plus récent au plus ancien, keyset sur l'`id` (UUIDv7, donc chronologique).

Filtres tous optionnels et cumulables : `user` (hex 32), `currency` (code), `type` (`experience|points|money`), `event` (slug, ex. `media_upload`), `from`/`to` (bornes sur `created_at`), `limit` (1..200, défaut 50), `cursor` (hex de la dernière écriture vue).

> Une plage `from`/`to` bornée **élague les partitions** (partitionnement mensuel sur `created_at`) : les investigations datées restent peu coûteuses.

Réponse : `{ "items": [ … ], "nextCursor": "<hex32>" | null }`. Chaque écriture expose `id, createdAt, userId, emitterId, currency, type, event, eventCode, amount, direction, referenceType, referenceId, contextLabel, isReversal, reversesId, status`.

**`GET /admin/users/{hex}/balances`** — soldes courants d'un compte, lus sur `user_balance` (jamais un `SUM` du journal). Un compte sans mouvement répond `200` avec une liste vide (absence de solde ≠ ressource manquante). Réponse : `{ "userId", "balances": [ { currency, type, balance, updatedAt } ] }`.

### Mutations (sensibles, journalisées)

**`POST /admin/ledger/entries`** — crédit ou débit manuel (événement `adjustment`).

```json
{ "userId": "<hex32>", "currency": "POINTS", "amount": "50", "reason": "geste commercial — litige #1234" }
```

- `amount` est une **chaîne décimale signée** (`+` crédit / `−` débit) — jamais un flottant. C'est le chemin pour **attribuer spontanément** des points, de l'XP, de l'argent ou toute monnaie du catalogue.
- `reason` est **obligatoire** (≤ 120 car.) et **strictement interne** : il part dans `user_action_log` avec l'identité de l'opérateur, et n'est **jamais** exposé au bénéficiaire (une note d'opérateur n'a pas à être lue par l'utilisateur).

> **Nom affiché au bénéficiaire.** Dans son historique (`GET /api/users/me/transactions`), l'utilisateur voit un **libellé unique** partagé par toutes les attributions/retraits manuels — `eventLabel`, rendu depuis `resources/lang/<locale>/ledger.php` → `events.adjustment` (défaut « Ajustement » / « Adjustment »). Modifier cette seule ligne renomme **tout l'historique, passé comme futur** : rien n'est figé ligne par ligne. `contextLabel` reste `null` sur ces écritures.
- Réponses : `201` l'écriture créée · `400` corps malformé · `404` utilisateur inconnu · `422` erreurs de validation, monnaie inconnue/inactive, précision non respectée, ou **fonds insuffisants** (le débit franchirait le plancher).

**`POST /admin/ledger/entries/{id}/reverse`** — annule une écriture. Corps optionnel `{ "reason": "…" }`.

- Crée l'écriture **inverse** (même monnaie, même événement, même référence, montant négatté, `reversesId` renseigné) et bascule l'originale en `reversed`. Le solde est ramené en conséquence.
- Le plancher de découvert n'est **pas** appliqué ici : une correction doit toujours aboutir, même si elle rend le solde négatif.
- Réponses : `201` l'écriture inverse · `400` id invalide · `404` écriture inconnue · `409` écriture déjà annulée.

**Audit** : les deux mutations écrivent dans `hxa_bo.user_action_log` (actions `user.ledger.adjust` et `user.ledger.reverse`) avec l'opérateur nominatif quand le token staff est utilisé, `NULL` avec le token statique — voir [Journal d'audit](#journal-daudit).

---

## Référentiel des thèmes (`/admin/topics`)

Gestion du référentiel `topic` — les 135 thèmes qui qualifient les médias, les feeds et les centres d'intérêt des utilisateurs (cf. la section Topics de `docs/api.md`).

> **Ce qui se pilote ici, et ce qui NE s'y pilote pas.**
> Le back-office gère la **structure** : slug, hiérarchie, emoji, ordre d'affichage, activation.
> Les **libellés ne se modifient PAS ici** : ils vivent dans `resources/lang/<locale>/topics.php`, versionnés avec le code et relus en PR (même dispositif que les badges). Envoyer `label` ou `description` renvoie `422` avec l'explication. Chaque réponse expose néanmoins un bloc `labels` en **lecture seule** — `resolved` (ce que rend chaque locale aujourd'hui) et `missing` (les locales encore à traduire), pour voir d'un coup d'œil ce qui reste à faire.

**Deux invariants tenus par l'API**

- **`level` est dérivé**, jamais saisi : aucun parent → racine (`1`), au moins un parent → sous-thème (`2`). C'est ce qui rend impossible l'incohérence héritée de la saisie manuelle (`city` et `countryside` sont déclarés niveau 2 sans aucun parent) — et c'est aussi la façon de les corriger : leur poser des parents, ou envoyer `[]` pour en faire de vraies racines.
- **La hiérarchie est un graphe acyclique.** Un thème peut légitimement dépendre de plusieurs parents (14 des 135 le font), mais un cycle est refusé (`409`) : sans cette garde, le parcours de hiérarchie bouclerait indéfiniment.

| Endpoint | Effet |
|---|---|
| `GET /admin/topics` | Référentiel complet. Filtres cumulables `?active=true\|false`, `?level=1\|2`, `?parent=<slug>`, `?q=<texte>`. `{items, total}` |
| `GET /admin/topics/{slug}` | Détail + `parents`, `children` et `usage` (compteurs de références) · `404` si inconnu |
| `POST /admin/topics` | Création · `201` · `409` slug pris · `422` validation / parent inconnu |
| `PUT /admin/topics/{slug}` | Mise à jour **partielle** : `emoji`, `position`, `isActive` |
| `DELETE /admin/topics/{slug}` | Suppression **gardée** · `409` si le thème est référencé |
| `PUT /admin/topics/{slug}/parents` | Fixe la **liste complète** des parents · `409` si cycle |

**Corps de `POST /admin/topics`** (JSON plat)

```json
{
  "slug": "street-art",     // requis, minuscules-tirets, unique, IMMUABLE ensuite
  "parents": ["arts"],      // optionnel ; absent ou [] = thème racine
  "emoji": "🎨",            // optionnel
  "position": 42,           // optionnel (défaut 0)
  "isActive": true          // optionnel (défaut true)
}
```

- `slug` est **immuable** : il identifie le thème **et** sert de clé à son libellé dans les fichiers lang — le renommer effacerait silencieusement ses traductions (`422` si le corps l'essaie).
- Un thème fraîchement créé n'a pas encore de traduction : il s'affiche avec son slug brut jusqu'à ce que les fichiers lang partent en PR. `labels.missing` liste précisément les locales à compléter.

**Suppression : retirer plutôt que supprimer.** Les clés étrangères sont en `CASCADE` — supprimer un thème référencé le retirerait silencieusement de la sélection de chaque utilisateur, de chaque média et de chaque feed. L'endpoint refuse donc (`409`) dès qu'il est utilisé ou qu'il a des enfants, en renvoyant le détail de l'impact et l'alternative sûre :

```json
{
  "error": "Topic `travel` is still referenced and cannot be deleted.",
  "usage": { "users": 2, "media": 5, "socialFeeds": 0, "children": 11 },
  "suggestion": "Retire it instead: PUT /admin/topics/travel {\"isActive\": false}"
}
```

Un thème **retiré** (`isActive: false`) disparaît du catalogue public et du sélecteur, mais les utilisateurs qui l'avaient déjà coché le conservent (`getMine` renvoie délibérément les lignes archivées).

**`PUT /admin/topics/{slug}/parents`** — sémantique d'ensemble (ce que vous envoyez devient la liste) : `{ "parents": ["nature", "travel"] }`. Un tableau vide promeut le thème en racine. `level` est réaligné automatiquement.

---

## 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.
