---
title: "Badges (gamification)"
description: "Système de badges gagnés à mesure que l'utilisateur déclenche des actions tracées (upload, follower reçu, like reçu, commentaire posté, etc.). Le catalogue…"
---

Système de badges gagnés à mesure que l'utilisateur déclenche des actions tracées (upload, follower reçu, like reçu, commentaire posté, etc.). Le catalogue est seedé via le BO ; le code applicatif ne fait que **lire** la table `badge` + ses tiers et **écrire** dans `user_badge` au passage de palier.

## Modèle de données

Trois tables :
- `badge` — entrée du catalogue : `slug` (identité publique stable), `type` (`unique` | `level`), `metric` (clé du compteur déclencheur, ex. `media.uploaded`), `position`, `is_active`. Index couvrant `(metric, is_active, position)` pour le chemin chaud du service d'award.
- `badge_tier` — palier achievable : `level` (1..5), `threshold` (valeur min du compteur), `xp_reward` (XP cumulés à l'obtention). PK composite `(badge_id, level)`. Un badge `unique` a exactement 1 tier ; un badge `level` en a 1..5.
- `user_badge` — possession : `(user_id, badge_id) → level`. PK composite, monotone (`level` ne descend jamais — `BadgeAwardService` interroge le niveau actuel avant l'upsert et ignore une replay obsolète). `earned_at` figé à la première obtention, `updated_at` bumpé à chaque montée de niveau.

> Les libellés et descriptions vivent dans les catalogues `resources/lang/<locale>/badges.php`, indexés par `slug` (clés `badges.<slug>.label` et `badges.<slug>.description`). La table `badge_translation` a été supprimée — toute évolution de wording passe par une PR sur ces fichiers (et non par le BO).
>
> Les **icônes** sont des assets statiques (par défaut SVG) hébergés dans un dépôt séparé : leurs URLs sont reconstruites à partir du `slug` et du `level` par le sérialiseur — voir _Icônes_ ci-dessous.

## Identité API : `slug`

L'**identifiant JSON:API d'une ressource `badges` est le `slug`** (`first-upload`, `level-uploader`, …), pas l'UUID hex. L'UUID interne reste exposé en attribut `internalId` pour le debug / cross-référence outils admin uniquement.

## Métriques supportées (`BadgeMetric`)

Chaque badge cible **une** métrique :

| Valeur de `metric`          | Déclencheur côté code                                                       |
|-----------------------------|-----------------------------------------------------------------------------|
| `media.uploaded`            | Compteur d'upload d'un user (incrémente sur chaque `POST /users/me/media`). |
| `follower.received`         | Nombre de followers de l'utilisateur (compteur sur `user_stats`).           |
| `following.count`           | Nombre d'utilisateurs suivis.                                               |
| `reaction.like.received`    | Compteur monotone des `j'aime` reçus sur l'ensemble des médias du user (`user_stats.num_likes_received`, alimenté par trigger SQL `AFTER INSERT WHERE value='like'` ; jamais décrémenté sur unlike/flip). Alimente `crowd-favorite`. |
| `comment.posted`            | Compteur monotone des commentaires postés par le user **sur les médias des autres** (self-comments exclus côté trigger SQL `user_stats.num_external_comments`, jamais décrémenté). Alimente `wordsmith`. |
| `sponsorship.valid`         | Filleuls confirmés (mail validé).                                           |
| `user.level`                | Niveau utilisateur (dérivé de `experience`, voir formule plus haut).        |

Le caller passe **directement la nouvelle valeur du compteur** à `BadgeAwardService::onMetric()` — pas de `COUNT(*)` à la volée (les compteurs sont déjà dénormalisés sur `user_stats` ou autre, le service y croit).

## Mécanique d'award (`BadgeAwardService`)

1. Pour chaque badge actif tracant la métrique mutée, `Badge::tierFor($newValue)` calcule le tier le plus haut atteint.
2. `UserBadgeRepository::upsertLevel()` est un upsert **monotone** (le `ON DUPLICATE KEY UPDATE` est protégé par un guard applicatif qui refuse de redescendre le niveau). Retourne le `previousLevel`.
3. Pour **chaque palier traversé** dans le bump (ex. L0 → L3 = 3 paliers), l'`xp_reward` correspondant est cumulé puis crédité via `ExperienceService::award()` (qui rejoint la tx ouverte par le service d'award — XP et état badge restent atomiques).
4. **Après commit**, une notification `badge.earned` est dispatchée **par palier traversé** (pas seulement le dernier). Backfill L0 → L3 produit donc 3 lignes de feed, 3 entrées d'audit XP, 1 seule ligne `user_badge` à L3. Chaque dispatch porte un `dedupKey` `badge.earned:<badgeHex>:<level>` — un replay ne crée pas de doublon.
5. Failure du dispatch après commit = soft-fail (le feed est best-effort, l'état persisté est correct).

## Visibilité

Par décision produit : **les badges sont publics** (même pattern que `/followers`). N'importe qui peut consulter les badges gagnés par n'importe quel utilisateur via `/users/{userId}/badges`. Pas de gate de confidentialité.

## Icônes

Le serveur ne sert pas les SVG : il ne fait que construire leurs URLs (`BadgeIconUrlBuilder`). Conventions :

- `lockedIcon`  → `<BADGE_ICON_BASE_URL>/<slug>/locked.<BADGE_ICON_EXT>` — silhouette grisée commune au badge, exposée à la racine de la ressource.
- `tiers[].icon` → `<BADGE_ICON_BASE_URL>/<slug>/<level>.<BADGE_ICON_EXT>` — icône débloquée propre à chaque tier.

Le front choisit `lockedIcon` tant que `earned` est `null` (ou si `earned.level < tier.level`), `tiers[i].icon` sinon.

## Variables d'environnement

| Var                    | Défaut                                       | Effet                                                                                            |
|------------------------|----------------------------------------------|--------------------------------------------------------------------------------------------------|
| `BADGE_MAX_RESULTS`    | `200`                                        | Cap serveur sur `/api/badges` (`meta.truncated = true` + `meta.maxResults` si atteint).          |
| `BADGE_ICON_BASE_URL`  | `http://hexatrip-static.dev.com/badges`      | Racine publique des assets icônes (pas de slash final). Pointe sur le CDN en prod.               |
| `BADGE_ICON_EXT`       | `svg`                                        | Extension des icônes (sans point). Permet de basculer SVG ↔ WebP/PNG sans toucher au code.       |

Les `threshold` et `xp_reward` **ne sont pas** des envs : ils vivent dans `badge_tier` pour rebalancer l'économie depuis le BO sans redéploiement.

## `GET /api/badges`

Catalogue public des badges actifs, localisé. Aucun paramètre de query, aucune pagination (catalogue borné par produit). Tri systématique `(position, id)`.

- **Auth** : optionnelle. Si un Bearer valide est fourni, chaque ressource porte un `earned` non-null avec le niveau et l'`earnedAt` du viewer. Sinon `earned` vaut `null` partout.
- **Action** : [ListBadgesAction](../src/Http/Action/Api/Badges/ListBadgesAction.php)
- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "badges",
      "id":   "first-upload",
      "attributes": {
        "slug":         "first-upload",
        "type":         "unique",
        "metric":       "media.uploaded",
        "label":        "Premier cliché",
        "description":  "Votre tout premier média publié.",
        "lockedIcon":   "https://cdn.example/badges/first-upload/locked.svg",
        "position":     1,
        "isActive":     true,
        "maxLevel":     1,
        "maxThreshold": 1,
        "tiers": [
          {
            "level":     1,
            "threshold": 1,
            "xpReward":  100,
            "icon":      "https://cdn.example/badges/first-upload/1.svg"
          }
        ],
        "earned": {
          "level":         1,
          "earnedAt":      "2026-06-10T14:32:11+00:00",
          "updatedAt":     null,
          "nextThreshold": null,
          "nextXpReward":  null
        },
        "internalId": "9c3c1a18-…",
        "createdAt":  "2026-05-01T10:00:00+00:00",
        "updatedAt":  null
      }
    }
  ],
  "meta":  { "total": 24 },
  "links": { "self": "https://api.example/api/badges" }
}
```

`earned = null` quand l'appel est anonyme ou que le viewer n'a pas (encore) ce badge. `nextThreshold` / `nextXpReward` sont les attributs du tier au-dessus du niveau actuel (utiles pour la barre de progression front) ; ils valent `null` quand le viewer est au sommet.

`meta.truncated = true` + `meta.maxResults` apparaissent si le BO a inséré plus de `BADGE_MAX_RESULTS` lignes actives.

---

## `GET /api/users/me/badges`

Badges actuellement détenus par l'utilisateur authentifié (niveau ≥ 1). Tri `(position, id)`.

- **Auth** : requise (Bearer)
- **Action** : [GetMyBadgesAction](../src/Http/Action/Api/Users/Badge/GetMyBadgesAction.php)
- **Réponse `200`** : identique à `GET /api/badges`, mais ne contient **que les badges gagnés** et `earned` est toujours non-null. Les badges depuis désactivés par le BO restent présents (`isActive = false`) pour ne pas casser la galerie historique.
- **Collection vide** : `data: []`, `meta.total: 0`, `links.self` — jamais de 404 si l'utilisateur n'a juste rien gagné.

---

## `GET /api/users/{userId}/badges`

Variante publique du précédent : badges gagnés par n'importe quel utilisateur.

- **Auth** : non requise (badges publics)
- **Action** : [GetUserBadgesAction](../src/Http/Action/Api/Users/Badge/GetUserBadgesAction.php)
- **Erreurs** :
  - `422` si `userId` malformé (`pointer = "/data/id"`)
  - `404` si l'utilisateur n'existe pas
- **Réponse `200`** : même forme que `/users/me/badges`. Collection vide légitime si l'utilisateur n'a aucun badge.

---
