---
title: "Followers / découverte"
description: "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.…"
---

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](#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)](#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`](#get-apiusersuseridrelationship).

---

## `POST /api/users/me/following/{targetUserId}`

Suit la cible.

- **Auth** : requise (Bearer)
- **Action** : [FollowUserAction](../src/Http/Action/Api/Users/FollowUserAction.php)
- **Path** : `{targetUserId}` UUID canonique (8-4-4-4-12).
- **Idempotent** : si la relation existe déjà, `200` quand même, aucun compteur n'est bumped.
- **Règles métier** :
  - Auto-follow interdit (`422` `follow.selfFollow`)
  - Cible inexistante (`404` `follow.targetNotFound`)
  - Cible bannie (`403` `follow.targetBanned`)
  - Acteur non confirmé (`403` `follow.actorNotConfirmed`)
  - Acteur banni (`403` `follow.actorBanned`)
  - **Rate limit** (`429` `follow.rateLimited`) : au-delà de `FOLLOW_MAX_PER_WINDOW` nouveaux follows (défaut **30**) sur une fenêtre glissante de `FOLLOW_WINDOW_MINUTES` minute(s) (défaut **1**), la réponse renvoie `429` avec l'en-tête `Retry-After` (secondes) et `meta.retryAfterSeconds`. Seuls les follows **réellement créés** consomment le budget — les re-follows idempotents et les unfollows ne comptent pas.
- **Réponse `200 OK`** :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "userFollows",
    "id":   "<followerUuid>:<targetUuid>",
    "attributes": {
      "followerId": "0193f4a2-…",
      "targetId":   "0193f4b0-…"
    }
  }
}
```

---

## `DELETE /api/users/me/following/{targetUserId}`

Cesse de suivre la cible.

- **Auth** : requise (Bearer)
- **Action** : [UnfollowUserAction](../src/Http/Action/Api/Users/UnfollowUserAction.php)
- **Idempotent** : appelé sur une relation inexistante, retourne quand même `200` (aucun compteur impacté).
- **Réponse `200 OK`** : ressource `userFollows` avec `unfollowed: true`.

---

## `GET /api/users/me/following`

Liste paginée des utilisateurs que l'utilisateur authentifié suit (`followed_at DESC`).

- **Auth** : requise (Bearer)
- **Action** : [ListMyFollowingAction](../src/Http/Action/Api/Users/ListMyFollowingAction.php)
- **Query** : `?cursor=<opaque>` (page suivante), `?before=<opaque>` (page précédente), `?limit=<1..100>` (défaut `20`)
- **Réponse `200 OK`** : `data[]` de ressources `users` (avec `followedAt`, `followersCount`, `followingCount`, et les flags de relation `isFollowedByMe = true` / `isFollowingMe` / `isMutual`), navigation via l'objet `links`. `meta` : `{ "limit": 20, "total": <user_stats.following> }` — le compteur exact est lu sur `user_stats` (maintenu par triggers MySQL), donc gratuit.

---

## `GET /api/users/me/followers`

Liste paginée des utilisateurs qui suivent l'utilisateur authentifié.

- **Auth** : requise (Bearer)
- **Action** : [ListMyFollowersAction](../src/Http/Action/Api/Users/ListMyFollowersAction.php)
- **Query** : idem.
- **Réponse `200 OK`** : `data[]` de ressources `users` ; `isFollowedByMe` indique si je suis aussi ce follower (réciprocité), `isFollowingMe = true` par définition (ce sont mes followers) et `isMutual = isFollowedByMe`. `meta.total = <user_stats.followers>` (compteur dénormalisé).

---

## `GET /api/users/{userId}/following`

Liste publique des utilisateurs que `{userId}` suit.

- **Auth** : aucune
- **Action** : [ListUserFollowingAction](../src/Http/Action/Api/Users/ListUserFollowingAction.php)
- **Path** : `{userId}` UUID canonique.
- **Erreurs** : `422` si UUID invalide, `404` si l'utilisateur n'existe pas.
- **Réponse `200 OK`** : ressources `users` **sans** `isFollowedByMe` (pas de viewer authentifié). `meta.total = <user_stats.following>` de l'anchor.

---

## `GET /api/users/{userId}/followers`

Liste publique des utilisateurs qui suivent `{userId}`.

- **Auth** : aucune
- **Action** : [ListUserFollowersAction](../src/Http/Action/Api/Users/ListUserFollowersAction.php)
- Mêmes erreurs / forme de réponse que ci-dessus. `meta.total = <user_stats.followers>` de l'anchor.

---

## `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](../src/Http/Action/Api/Users/GetUserRelationshipAction.php)
- **Path** : `{userId}` UUID canonique.
- **Erreurs** : `422` si UUID invalide, `404` si l'utilisateur n'existe pas.
- **Cas particulier** : relation avec soi-même → `isSelf = true`, tous les flags `false` (aucune ligne `user_follow` n'existe), pas de lookup.
- **Réponse `200 OK`** :

```json
{
  "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`

Recherche / découverte d'utilisateurs via l'index Meilisearch (`MEILISEARCH_USERS_INDEX`).

- **Auth** : aucune (si la requête transporte malgré tout un Bearer valide, `isFollowedByMe` est calculé pour le viewer).
- **Action** : [SearchUsersAction](../src/Http/Action/Api/Users/SearchUsersAction.php)
- **Query** :
  - `?q=<texte>` : full-text optionnel sur `username` / `nickname`. Vide = pur browse.
  - `?sort=popular` → tri par `stats.num_user_follower:desc`
  - `?sort=recent`  → tri par `joined_at:desc`
  - sinon : pertinence Meilisearch.
  - `?limit=<1..100>` (défaut `20`), `?offset=<int>` (défaut `0`).
- **Pipeline** : Meilisearch retourne des `id` (UUID hex), Hydrogen ré-hydrate les entités `User` et `UserStats` depuis MySQL pour garantir une forme `users` strictement identique aux autres endpoints (avatar/cover résolus, `isBanned/isConfirmed` à jour…).
- **Réponse `200 OK`** :

```json
{
  "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.last` est disponible ici (contrairement aux endpoints keyset) car `meta.totalHits` permet de le calculer.
- **`503`** : si Meilisearch est indisponible/inconnu (l'erreur API est propagée dans `errors[].detail`).

---

## `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](../src/Http/Action/Api/Leaderboard/GetLeaderboardAction.php)
- **Éligibilité** : seuls les comptes **confirmés, non supprimés et non bannis**
  sont classés.
- **Query** :
  - `?metric=<slug>` (défaut `likesReceived`) — whitelist stricte, `400` sinon :

    | Slug | Classe par | Colonne source |
    |---|---|---|
    | `media` | médias publiés | `user_stats.num_media` |
    | `likesReceived` | likes reçus | `user_stats.num_likes_received` |
    | `followers` | followers | `user_stats.num_user_follower` |
    | `xp` | expérience / niveau | `user.experience` |
    | `comments` | commentaires reçus | `user_stats.num_comments` |
    | `views` | vues de profil | `user_stats.num_views` |
    | `commentsPosted` | commentaires postés | `user_stats.num_external_comments` |

  - `?limit=<1..100>` (défaut `20`), `?offset=<int>` (défaut `0`).
- **Tri** : `<métrique> DESC`, départage stable par `id DESC`. Compteurs
  pré-agrégés (triggers MySQL) + index dédiés → pas de `COUNT(*)` à chaud.
- **Forme** : chaque entrée est une ressource `leaderboardEntries`
  (`id` = UUID de l'utilisateur) portant `rank`, `metric`, `value` et un bloc
  `user` public inline (même résumé que l'`author` des médias/commentaires).
- **Réponse `200 OK`** :

```json
{
  "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 calculer `links.last`.
- **`400`** : `metric` inconnue (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_daily` déjà rollupée), les autres métriques nécessitant de
> nouveaux rollups quotidiens par utilisateur.

---
