Skip to content

Badges (gamification)

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.

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.

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.

Chaque badge cible une métrique :

Valeur de metricDéclencheur côté code
media.uploadedCompteur d’upload d’un user (incrémente sur chaque POST /users/me/media).
follower.receivedNombre de followers de l’utilisateur (compteur sur user_stats).
following.countNombre d’utilisateurs suivis.
reaction.like.receivedCompteur 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.postedCompteur 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.validFilleuls confirmés (mail validé).
user.levelNiveau 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).

  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).

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é.

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.

VarDéfautEffet
BADGE_MAX_RESULTS200Cap serveur sur /api/badges (meta.truncated = true + meta.maxResults si atteint).
BADGE_ICON_BASE_URLhttp://hexatrip-static.dev.com/badgesRacine publique des assets icônes (pas de slash final). Pointe sur le CDN en prod.
BADGE_ICON_EXTsvgExtension 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.

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
  • Réponse 200 OK :
{
"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.


Badges actuellement détenus par l’utilisateur authentifié (niveau ≥ 1). Tri (position, id).

  • Auth : requise (Bearer)
  • Action : GetMyBadgesAction
  • 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é.

Variante publique du précédent : badges gagnés par n’importe quel utilisateur.

  • Auth : non requise (badges publics)
  • Action : GetUserBadgesAction
  • 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.