---
title: "Signalements (modération)"
description: "API permettant aux utilisateurs authentifiés de signaler un média, un compte ou un commentaire pour examen par la modération. Les lignes sont persistées da…"
---

API permettant aux utilisateurs authentifiés de signaler un média, un compte ou un commentaire pour examen par la modération. Les lignes sont persistées dans la base back-office (`hxa_bo.report`) — la base applicative ne voit jamais ces données. Les modérateurs lisent la file via `/admin/reports` (voir [docs/admin.md](admin.md)).

## Modèle de données

Table polymorphe `hxa_bo.report` :

| Colonne                | Type                                              | Notes |
|------------------------|---------------------------------------------------|-------|
| `id`                   | `BINARY(16)`                                      | UUID v4. |
| `reporter_user_id`     | `BINARY(16)`                                      | Auteur du signalement. **Jamais exposé** côté API publique. |
| `target_type`          | `ENUM('media','user','comment')`                  | Discriminateur. |
| `target_id`            | `BINARY(16)`                                      | Référence vers `media.id` / `user.id` / `media_comment.id`. Pas de FK déclarée : la modération doit survivre à un hard-delete de la cible. |
| `reason_code`          | `VARCHAR(64)`                                     | Slug whitelisté par `config/report_reasons.php`. |
| `details`              | `TEXT NULL`                                       | Texte libre optionnel (capé à `REPORT_DETAILS_MAX_LENGTH`). |
| `status`               | `ENUM('pending','reviewed','action_taken','dismissed')` | Cycle de vie côté modération. |
| `resolved_by_user_id`  | `BINARY(16) NULL`                                 | Opérateur qui a tranché (peut être NULL pour un job automatisé). |
| `resolved_at`          | `DATETIME NULL`                                   | Horodatage du verdict. |
| `resolution_note`      | `TEXT NULL`                                       | Commentaire interne du modérateur. |
| `created_at` / `updated_at` | `DATETIME`                                   | |

Index :
- `UNIQUE (reporter_user_id, target_type, target_id)` — idempotence du POST + check « ai-je déjà signalé X ? ».
- `KEY (target_type, target_id, status)` — agrégat « combien de signalements ouverts sur cette cible ? ».
- `KEY (status, created_at)` — scan de file modérateur.

## Codes de motif (`reason_code`)

Whitelist statique (`config/report_reasons.php`), différente par `target_type` :

| Motif            | media | user | comment | Description |
|------------------|:-----:|:----:|:-------:|-------------|
| `spam`           | ✓     | ✓    | ✓       | Contenu/compte/commentaire spam ou répétitif. |
| `harassment`     | ✓     | ✓    | ✓       | Harcèlement ciblé. |
| `hate_speech`    | ✓     | ✓    | ✓       | Discours de haine. |
| `sexual_content` | ✓     | —    | ✓       | Contenu sexuel explicite non signalé. |
| `violence`       | ✓     | —    | ✓       | Violence graphique. |
| `copyright`      | ✓     | —    | —       | Atteinte aux droits d'auteur. |
| `fake_account`   | —     | ✓    | —       | Compte bot ou fake. |
| `impersonation`  | —     | ✓    | —       | Usurpation d'identité. |
| `other`          | ✓     | ✓    | ✓       | Motif libre (à expliciter dans `details`). |

Un motif hors whitelist pour le `target_type` ⇒ 422 `report.invalidReason`.

## Variables d'environnement

- `REPORT_DETAILS_MAX_LENGTH` (défaut `1000`) — cap glyphes UTF-8 sur `details`.
- `REPORT_RATE_LIMIT_PER_DAY` (défaut `20`) — nombre maximal de signalements distincts qu'un même utilisateur peut soumettre sur une fenêtre glissante de 24h. Un re-signalement de la même cible (qui retourne la ligne existante) ne consomme PAS de quota.

## Règles métier (validées)

- **Auth requise** — un signalement anonyme serait du bruit.
- **NO self-report** — un utilisateur ne peut PAS signaler son propre média / compte / commentaire (403 `report.selfReport`).
- **NO XP**, **NO notification** au signalé, **NO compteur public** — l'acte reste strictement privé entre le rapporteur et la modération.
- **Idempotence** : POST répété sur la même cible par le même rapporteur ⇒ retour 200 avec la ligne existante (le verdict d'une cible déjà résolue est **préservé**, pas réouvert).
- **Anonymat côté API** : la ressource `reports` renvoyée n'expose **jamais** `reporterUserId` / `resolvedByUserId` / `resolutionNote`. Ces champs ne sont accessibles que via l'API admin.

## Codes d'erreur (`report.*`)

| `meta.code`                | HTTP | Quand |
|----------------------------|:----:|-------|
| `report.actorNotConfirmed` | 403  | L'utilisateur n'a pas confirmé son email. |
| `report.actorBanned`       | 403  | Compte banni. |
| `report.selfReport`        | 403  | Le rapporteur est le propriétaire de la cible. |
| `report.targetNotFound`    | 404  | La cible n'existe pas. |
| `report.reasonMissing`     | 422  | Champ `reason` manquant ou vide. |
| `report.invalidReason`     | 422  | `reason` hors whitelist pour ce `target_type`. |
| `report.detailsTooLong`    | 422  | `details` dépasse `REPORT_DETAILS_MAX_LENGTH`. |
| `report.rateLimited`       | 429  | `REPORT_RATE_LIMIT_PER_DAY` atteint sur 24h. |

## Ressource `reports`

```jsonc
{
  "type": "reports",
  "id":   "<hex>",
  "attributes": {
    "targetType": "media | user | comment",
    "targetId":   "<hex>",
    "reason":     "spam | harassment | …",
    "status":     "pending | reviewed | action_taken | dismissed",
    "createdAt":  "2026-06-13T12:34:56+00:00"
  }
}
```

`reporterUserId`, `details`, `resolvedByUserId`, `resolvedAt`, `resolutionNote` sont **volontairement absents** côté public.

---

## `POST /api/media/{mediaId}/reports`

Signale un média. `{mediaId}` au format UUID dashed (cohérent avec les autres routes `/media/{mediaId}/*`).

**Auth** : requise.

**Corps** (formes acceptées) :

```jsonc
// Plate
{ "reason": "spam", "details": "optional context" }

// JSON:API
{
  "data": {
    "type": "reports",
    "attributes": { "reason": "spam", "details": "optional context" }
  }
}
```

`details` est optionnel.

**Réponses** :
- `201 Created` — première fois (la ressource `reports` est retournée).
- `200 OK`     — re-signalement de la même cible par le même utilisateur ; la ligne existante est retournée telle quelle (`status` préservé, même si déjà résolue).
- `403`        — voir codes d'erreur.
- `404`        — média introuvable.
- `422`        — payload invalide.
- `429`        — quota journalier atteint.

---

## `POST /api/users/{userHex}/reports`

Signale un compte utilisateur. `{userHex}` au format hex (32 caractères, sans tirets), aligné sur les autres endpoints user-centric.

**Auth** : requise.

**Corps** : identique à `/api/media/{mediaId}/reports` ; les motifs `fake_account` et `impersonation` deviennent valides ici.

**Réponses** : mêmes statuts.

---

## `POST /api/media/comments/{commentId}/reports`

Signale un commentaire. `{commentId}` au format UUID dashed (cohérent avec `/media/comments/{commentId}/*`).

**Auth** : requise.

**Réponses** : mêmes statuts. Le check self-report s'appuie sur `media_comment.user_id` — signaler son propre commentaire est un 403 `report.selfReport`, pas un 404.

---
