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.
Modèle de données
Section titled “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 badgeuniquea exactement 1 tier ; un badgelevelen a 1..5.user_badge— possession :(user_id, badge_id) → level. PK composite, monotone (levelne descend jamais —BadgeAwardServiceinterroge le niveau actuel avant l’upsert et ignore une replay obsolète).earned_atfigé à la première obtention,updated_atbumpé à chaque montée de niveau.
Les libellés et descriptions vivent dans les catalogues
resources/lang/<locale>/badges.php, indexés parslug(clésbadges.<slug>.labeletbadges.<slug>.description). La tablebadge_translationa é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
sluget dulevelpar le sérialiseur — voir Icônes ci-dessous.
Identité API : slug
Section titled “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)
Section titled “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)
Section titled “Mécanique d’award (BadgeAwardService)”- Pour chaque badge actif tracant la métrique mutée,
Badge::tierFor($newValue)calcule le tier le plus haut atteint. UserBadgeRepository::upsertLevel()est un upsert monotone (leON DUPLICATE KEY UPDATEest protégé par un guard applicatif qui refuse de redescendre le niveau). Retourne lepreviousLevel.- Pour chaque palier traversé dans le bump (ex. L0 → L3 = 3 paliers), l’
xp_rewardcorrespondant est cumulé puis crédité viaExperienceService::award()(qui rejoint la tx ouverte par le service d’award — XP et état badge restent atomiques). - Après commit, une notification
badge.earnedest 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 ligneuser_badgeà L3. Chaque dispatch porte undedupKeybadge.earned:<badgeHex>:<level>— un replay ne crée pas de doublon. - Failure du dispatch après commit = soft-fail (le feed est best-effort, l’état persisté est correct).
Visibilité
Section titled “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
Section titled “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
Section titled “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
Section titled “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
earnednon-null avec le niveau et l’earnedAtdu viewer. Sinonearnedvautnullpartout. - 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.
GET /api/users/me/badges
Section titled “GET /api/users/me/badges”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 etearnedest 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
Section titled “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
- Erreurs :
422siuserIdmalformé (pointer = "/data/id")404si 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.