---
title: "Commentaires"
description: "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 m…"
---

## 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.
- `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
  },
  "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/{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.

---
