Followers / découverte
Les compteurs num_user_follower / num_user_followed sont maintenus côté MySQL par les triggers t_add_follower et t_delete_follower attachés à user_follow. Hydrogen n’écrit jamais directement dans user_stats ; à la place, après chaque INSERT/DELETE sur user_follow, le service de follow pousse une mise à jour partielle vers l’index Meilisearch users (paramétrable via MEILISEARCH_USERS_INDEX, défaut users_dev) — best-effort, n’annule jamais l’écriture SQL.
Pagination : keyset opaque sur (followed_at, peerId). Paramètres ?cursor=… (page suivante), ?before=… (page précédente), ?limit=<1..100> (défaut 20). La navigation se fait via l’objet links JSON:API (self/first/prev/next) — voir conventions générales. Un curseur malformé renvoie 400.
Ressource users : la ressource utilisateur retournée dans ces listings inclut, en plus des attributs habituels, followersCount, followingCount, mediaCount, albumCount, commentsCount, likesReceivedCount, viewsCount, impressionsCount (lus depuis user_stats). likesReceivedCount est un compteur monotone (jamais décrémenté) du cumul des j'aime reçus sur l’ensemble des médias de l’utilisateur, maintenu par le trigger SQL trg_media_reaction_ai_likes_received. L’attribut followedAt reflète la date de mise en relation (ISO 8601). viewsCount et impressionsCount sont maintenus de manière asynchrone — voir Compteurs de profil (views + impressions).
Flags de relation (viewer authentifié) : quand la requête transporte un Bearer valide, chaque ressource users de ces listings porte trois booléens calculés en deux requêtes batch (une par direction) :
| Flag | Signification |
|---|---|
isFollowedByMe | le viewer suit ce pair |
isFollowingMe | ce pair suit le viewer (réciprocité entrante) |
isMutual | les deux à la fois (dérivé) |
Sur les endpoints publics /users/{userId}/* sans Bearer, ces trois flags sont omis (pas de contexte viewer) — ils ne sont jamais null, simplement absents. Pour interroger la relation avec un seul utilisateur sans paginer une liste, voir GET /api/users/{userId}/relationship.
POST /api/users/me/following/{targetUserId}
Section titled “POST /api/users/me/following/{targetUserId}”Suit la cible.
- Auth : requise (Bearer)
- Action : FollowUserAction
- Path :
{targetUserId}UUID canonique (8-4-4-4-12). - Idempotent : si la relation existe déjà,
200quand même, aucun compteur n’est bumped. - Règles métier :
- Auto-follow interdit (
422follow.selfFollow) - Cible inexistante (
404follow.targetNotFound) - Cible bannie (
403follow.targetBanned) - Acteur non confirmé (
403follow.actorNotConfirmed) - Acteur banni (
403follow.actorBanned) - Rate limit (
429follow.rateLimited) : au-delà deFOLLOW_MAX_PER_WINDOWnouveaux follows (défaut 30) sur une fenêtre glissante deFOLLOW_WINDOW_MINUTESminute(s) (défaut 1), la réponse renvoie429avec l’en-têteRetry-After(secondes) etmeta.retryAfterSeconds. Seuls les follows réellement créés consomment le budget — les re-follows idempotents et les unfollows ne comptent pas.
- Auto-follow interdit (
- Réponse
200 OK:
{ "jsonapi": { "version": "1.1" }, "data": { "type": "userFollows", "id": "<followerUuid>:<targetUuid>", "attributes": { "followerId": "0193f4a2-…", "targetId": "0193f4b0-…" } }}DELETE /api/users/me/following/{targetUserId}
Section titled “DELETE /api/users/me/following/{targetUserId}”Cesse de suivre la cible.
- Auth : requise (Bearer)
- Action : UnfollowUserAction
- Idempotent : appelé sur une relation inexistante, retourne quand même
200(aucun compteur impacté). - Réponse
200 OK: ressourceuserFollowsavecunfollowed: true.
GET /api/users/me/following
Section titled “GET /api/users/me/following”Liste paginée des utilisateurs que l’utilisateur authentifié suit (followed_at DESC).
- Auth : requise (Bearer)
- Action : ListMyFollowingAction
- Query :
?cursor=<opaque>(page suivante),?before=<opaque>(page précédente),?limit=<1..100>(défaut20) - Réponse
200 OK:data[]de ressourcesusers(avecfollowedAt,followersCount,followingCount, et les flags de relationisFollowedByMe = true/isFollowingMe/isMutual), navigation via l’objetlinks.meta:{ "limit": 20, "total": <user_stats.following> }— le compteur exact est lu suruser_stats(maintenu par triggers MySQL), donc gratuit.
GET /api/users/me/followers
Section titled “GET /api/users/me/followers”Liste paginée des utilisateurs qui suivent l’utilisateur authentifié.
- Auth : requise (Bearer)
- Action : ListMyFollowersAction
- Query : idem.
- Réponse
200 OK:data[]de ressourcesusers;isFollowedByMeindique si je suis aussi ce follower (réciprocité),isFollowingMe = truepar définition (ce sont mes followers) etisMutual = isFollowedByMe.meta.total = <user_stats.followers>(compteur dénormalisé).
GET /api/users/{userId}/following
Section titled “GET /api/users/{userId}/following”Liste publique des utilisateurs que {userId} suit.
- Auth : aucune
- Action : ListUserFollowingAction
- Path :
{userId}UUID canonique. - Erreurs :
422si UUID invalide,404si l’utilisateur n’existe pas. - Réponse
200 OK: ressourcesuserssansisFollowedByMe(pas de viewer authentifié).meta.total = <user_stats.following>de l’anchor.
GET /api/users/{userId}/followers
Section titled “GET /api/users/{userId}/followers”Liste publique des utilisateurs qui suivent {userId}.
- Auth : aucune
- Action : ListUserFollowersAction
- Mêmes erreurs / forme de réponse que ci-dessus.
meta.total = <user_stats.followers>de l’anchor.
GET /api/users/{userId}/relationship
Section titled “GET /api/users/{userId}/relationship”Sonde en un seul appel l’état de la relation entre le viewer authentifié et {userId} — évite au client de différ deux listings pour connaître la réciprocité.
- Auth : requise (Bearer) — la réponse est relative au viewer.
- Action : GetUserRelationshipAction
- Path :
{userId}UUID canonique. - Erreurs :
422si UUID invalide,404si l’utilisateur n’existe pas. - Cas particulier : relation avec soi-même →
isSelf = true, tous les flagsfalse(aucune ligneuser_follown’existe), pas de lookup. - Réponse
200 OK:
{ "jsonapi": { "version": "1.1" }, "data": { "type": "relationships", "id": "<viewerUuid>:<targetUuid>", "attributes": { "targetId": "0193f4b0-…", "isFollowedByMe": true, "isFollowingMe": false, "isMutual": false, "isSelf": false } }}GET /api/users/search
Section titled “GET /api/users/search”Recherche / découverte d’utilisateurs via l’index Meilisearch (MEILISEARCH_USERS_INDEX).
- Auth : aucune (si la requête transporte malgré tout un Bearer valide,
isFollowedByMeest calculé pour le viewer). - Action : SearchUsersAction
- Query :
?q=<texte>: full-text optionnel surusername/nickname. Vide = pur browse.?sort=popular→ tri parstats.num_user_follower:desc?sort=recent→ tri parjoined_at:desc- sinon : pertinence Meilisearch.
?limit=<1..100>(défaut20),?offset=<int>(défaut0).
- Pipeline : Meilisearch retourne des
id(UUID hex), Hydrogen ré-hydrate les entitésUseretUserStatsdepuis MySQL pour garantir une formeusersstrictement identique aux autres endpoints (avatar/cover résolus,isBanned/isConfirmedà jour…). - Réponse
200 OK:
{ "jsonapi": { "version": "1.1" }, "data": [ { "type": "users", "id": "…", "attributes": { … } } ], "links": { "self": "https://api.example/api/users/search?limit=20&q=alice", "first": "https://api.example/api/users/search?limit=20&q=alice", "prev": null, "next": "https://api.example/api/users/search?limit=20&offset=20&q=alice", "last": "https://api.example/api/users/search?limit=20&offset=40&q=alice" }, "meta": { "totalHits": 42, "limit": 20, "offset": 0, "query": "alice" }}- Pagination : offset-based (Meilisearch). Navigation via
links.{self,first,prev,next,last}—links.lastest disponible ici (contrairement aux endpoints keyset) carmeta.totalHitspermet de le calculer. 503: si Meilisearch est indisponible/inconnu (l’erreur API est propagée danserrors[].detail).
GET /api/leaderboard
Section titled “GET /api/leaderboard”Classement public all-time des utilisateurs sur une métrique pré-agrégée. Anonyme et cachable (aucune donnée propre au viewer dans la réponse).
- Auth : aucune.
- Action : GetLeaderboardAction
- Éligibilité : seuls les comptes confirmés, non supprimés et non bannis sont classés.
- Query :
-
?metric=<slug>(défautlikesReceived) — whitelist stricte,400sinon :Slug Classe par Colonne source mediamédias publiés user_stats.num_medialikesReceivedlikes reçus user_stats.num_likes_receivedfollowersfollowers user_stats.num_user_followerxpexpérience / niveau user.experiencecommentscommentaires reçus user_stats.num_commentsviewsvues de profil user_stats.num_viewscommentsPostedcommentaires postés user_stats.num_external_comments -
?limit=<1..100>(défaut20),?offset=<int>(défaut0).
-
- Tri :
<métrique> DESC, départage stable parid DESC. Compteurs pré-agrégés (triggers MySQL) + index dédiés → pas deCOUNT(*)à chaud. - Forme : chaque entrée est une ressource
leaderboardEntries(id= UUID de l’utilisateur) portantrank,metric,valueet un blocuserpublic inline (même résumé que l’authordes médias/commentaires). - Réponse
200 OK:
{ "jsonapi": { "version": "1.1" }, "data": [ { "type": "leaderboardEntries", "id": "6f1e3a2b-...", "attributes": { "rank": 1, "metric": "likesReceived", "value": 1240, "user": { "id": "6f1e3a2b-...", "username": "marco", "nickname": "Marco P.", "displayName": "Marco P.", "avatarUrl": "https://…", "isVerified": true, "level": 12, "levelProgress": 0.42 } } } ], "links": { "self": "https://api.example/api/leaderboard?metric=likesReceived&limit=20", "first": "https://api.example/api/leaderboard?metric=likesReceived&limit=20", "prev": null, "next": "https://api.example/api/leaderboard?metric=likesReceived&limit=20&offset=20", "last": "https://api.example/api/leaderboard?metric=likesReceived&limit=20&offset=80" }, "meta": { "metric": "likesReceived", "total": 1583, "limit": 20, "offset": 0 }}- Pagination : offset-based.
meta.total= nombre de comptes éligibles (exact, identique quelle que soit la métrique) → permet de calculerlinks.last. 400:metricinconnue (le détail liste les slugs autorisés).
Fenêtre temporelle : seul le all-time est exposé pour l’instant. Une déclinaison « cette semaine / ce mois » est envisageable d’abord sur les vues (table
user_view_dailydéjà rollupée), les autres métriques nécessitant de nouveaux rollups quotidiens par utilisateur.