---
title: "Réactions (likes / dislikes)"
description: "Un utilisateur authentifié peut liker ou disliker un média (mais"
---

Un utilisateur authentifié peut **liker** ou **disliker** un média (mais
**pas le sien** — `403`). Le couple `(media_id, user_id)` est unique :
poser une réaction écrase la précédente (flip like ↔ dislike), idempotent
si la même valeur est repostée. La suppression est elle aussi idempotente.

## Règles produit (validées)

- **Pas d'auto-réaction** : l'auteur d'un média ne peut ni le liker, ni le
  disliker → `403 Self reaction forbidden`.
- **Pas de XP / gamification** sur les réactions (contrairement aux
  uploads / follows).
- **Listings 100 % publics** (`/media/{id}/likes`, `/media/{id}/dislikes`,
  `/users/{userId}/likes`, `/users/{userId}/dislikes`) — site de tourisme,
  transparence sociale. Les dislikes sont également publics car la donnée
  est symétrique et ne crée pas de surface de harcèlement spécifique.
- **Notification owner = like uniquement, et seulement le premier**.
  - Un dislike ne notifie jamais.
  - Un flip (`dislike → like`, ou re-like après unreact dans la même
    session) ne re-notifie pas le propriétaire — dedup_key
    `media.reaction:<mediaHex>:<actorHex>`.

## Compteurs et `viewerReaction`

Toutes les ressources `medias` exposent désormais 5 compteurs
dénormalisés sur `media_stats` :

```json
"likesCount":       42,
"dislikesCount":    3,
"viewsCount":       1287,
"impressionsCount": 9412,
"commentsCount":    21
```

- `likesCount` / `dislikesCount` sont maintenus par triggers MySQL.
- `commentsCount` est maintenu applicativement par le service commentaires.
- `viewsCount` et `impressionsCount` sont maintenus **asynchronement**
  par le pipeline compteurs :
  - **view** = un appel à `GET /media/{hex}.{ext}` (servir le binaire).
    Déduplication par viewer sur une fenêtre glissante de
    `MEDIA_COUNTERS_VIEW_DEDUP_WINDOW_SECONDS` (1h par défaut).
  - **impression** = un média apparaît dans une collection
    (`/api/media/nearby`, `/api/media/in-bounds`, `/api/users/{id}/media`).
    Chaque média livré dans la réponse prend `+1`. Pas de dedup.
  - Les bots détectés via `jenssegers/agent` sont ignorés
    (`MEDIA_COUNTERS_IGNORE_BOTS=true`).
  - Les compteurs sont alimentés par un worker
    (`bin/media-counters-flush.php`, tick
    `MEDIA_COUNTERS_FLUSH_TICK_SECONDS`) : ils peuvent retarder l'activité
    réelle d'un tick avant d'apparaître sur la ressource.

Sur un appel **authentifié**, la ressource expose en plus l'état du
viewer vis-à-vis du média (clé omise si appelant anonyme — le client ne
peut donc rien inférer d'une absence) :

```json
"viewerReaction": "like"   // ou "dislike", ou null si pas de réaction
```

L'index Meilisearch `MEILISEARCH_MEDIA_INDEX` reçoit également
`likes_count` / `dislikes_count` / `views_count`. Pour pouvoir trier ou
filtrer dessus, ajouter ces clés à `filterableAttributes` /
`sortableAttributes` côté ops (one-shot).

## Orientation (`orientation`) sur la ressource `medias`

Chaque ressource `medias` expose un attribut **`orientation`** dérivé une
fois à l'upload depuis les dimensions publiées (post-resize). Quatre
valeurs possibles, choisies pour couvrir les vrais besoins UI :

| Valeur       | Règle                                        | Cas typique             |
|--------------|----------------------------------------------|-------------------------|
| `landscape`  | `width > height` et `width/height < 2.0`     | 4:3, 16:9, photos kit   |
| `portrait`   | `height > width`                              | photos mobiles, stories |
| `square`     | `width == height` (ou dimensions inconnues)   | crop 1:1, fallback      |
| `panorama`   | `width > height` et `width/height >= 2.0`     | panoramas stitchés, 18:9+|

Le seuil `panorama` est codé en dur dans
[MediaOrientationResolver::PANORAMA_RATIO_THRESHOLD](../src/Domain/Media/MediaOrientationResolver.php) ;
la migration SQL `add_media_orientation` applique exactement la même règle
pour le backfill. La valeur est :
- persistée dans la colonne `media.orientation` (`VARCHAR(16) NOT NULL`) ;
- indexée comme **facet Meili** filterable sur le document média ;
- exposée en clair dans `attributes.orientation` côté API.

Les deux endpoints geo (`/api/media/nearby`, `/api/media/in-bounds`) acceptent
un paramètre `?orientation=portrait` (whitelist stricte ; toute valeur hors
enum répond `422 Invalid orientation`). Pas de combinaison `OR` : un seul
bucket à la fois (le besoin produit ne s'est pas encore présenté pour `OR`).

**Ops après déploiement** (à exécuter dans cet ordre) :

1. Migration SQL :
   `database/migrations/2026_06_13_160000_add_media_orientation.sql` —
   ajoute la colonne et backfille les rows existants.
2. Re-push des settings Meili pour rendre `orientation` filterable :
   ```bash
   php bin/media-meili-apply-settings.php
   ```
3. Full-reindex de l'index `media_dev` pour propager le champ
   `orientation` sur les documents déjà présents (sinon les filtres ne
   matchent rien pour ces docs) — utiliser l'endpoint admin de reindex ou
   le bin script habituel.

## Biome (`biome`) sur la ressource `medias`

Chaque ressource `medias` expose un bloc **`biome`** dérivé du champ
`media.biome_id` (biome WWF terrestre, `1..15` — classification Olson
et al. 2001). Le bloc est **toujours présent** pour que le client puisse
binder sans test d'existence :

```json
"biome": { "id": 4, "name": "Forêts tempérées" }
```

- `null` quand le média n'a **pas** de biome (pas de GPS, ou point hors de
  tout polygone de biome — océan / zone non cartographiée).
- `id` : l'identifiant numérique WWF `1..15` (option `(a)` — pas de slug).
- `name` : le libellé **touristique** localisé, résolu depuis le catalogue
  i18n fichier `resources/lang/<locale>/biomes.php` (clé `biomes.names.<id>`),
  **jamais** le nom scientifique stocké en base (`biome.name`, qui reste
  source de vérité de debug côté SQL).

**Dérivation** : le biome est calculé une fois via la procédure stockée
`geo_v2.get_biome(latitude, longitude)`
([BiomeLookupService](../src/Domain/Media/BiomeLookupService.php)) :
- à l'upload, quand l'EXIF porte un couple GPS
  ([MediaUploadService](../src/Domain/Media/MediaUploadService.php)) ;
- lors d'une géolocalisation manuelle
  (`PUT /admin/media/{hex}/geo`) ;
- en backfill massif sur la longue traîne
  (`POST /admin/media/backfill-biome`, cf. [admin.md](admin.md)).

**Indexation** : `biome_id` est poussé comme **facet Meili** filterable sur
le document média — les endpoints geo (`/api/media/nearby`,
`/api/media/in-bounds`) acceptent un filtre `?biome=<1..15>`.

**Ops après déploiement** (à exécuter dans cet ordre) :

1. Migrations SQL :
   `database/migrations/2026_07_03_120000_create_biome_table.sql`
   (référentiel des 15 biomes) puis
   `database/migrations/2026_07_03_120100_add_media_biome_id.sql`
   (colonne `media.biome_id` + FK).
2. Re-push des settings Meili pour rendre `biome_id` filterable :
   ```bash
   php bin/media-meili-apply-settings.php
   ```
3. Backfill des médias existants ayant un GPS mais pas de biome :
   `POST /admin/media/backfill-biome` (boucle keyset jusqu'à `done=true`),
   puis full-reindex pour propager le facet sur l'index.

## Description libre (`description`) sur la ressource `medias`

Chaque ressource `medias` expose un attribut **`description`** : texte
libre écrit par le propriétaire (max `MEDIA_DESCRIPTION_MAX_LENGTH`
caractères, défaut **1024**). Stocké dans la table 1-1
`hxa.media_description` (FK `ON DELETE CASCADE` vers `media`).

- Valeur `null` quand aucune description n'est enregistrée. Le champ est
  **toujours présent** dans l'attribut payload pour que le client puisse
  binder sans test d'existence.
- Édité indépendamment du média :
  - À l'upload : champ `description` du multipart `POST /api/users/me/media`
    (s'applique à tous les fichiers du POST).
  - Après coup : `PUT /api/users/me/media/{mediaId}/description`
    (idempotent ; chaîne vide ⇒ suppression).
  - Suppression explicite :
    `DELETE /api/users/me/media/{mediaId}/description` (idempotent).
- Indexation Meilisearch : la description est incluse dans le document
  via [MediaIndexService](../src/Domain/Media/MediaIndexService.php) — toute
  mutation déclenche un `reindex($id)` pour rester en phase avec
  l'index full-text.
- Batch-load : les listings de medias chargent les descriptions en **un
  seul round-trip** via `MediaDescriptionRepository::findManyFor()` (pas
  d'N+1).

Erreur de validation : `422 descriptionTooLong` (source-pointer
`/data/attributes/description`) si la longueur dépasse
`MEDIA_DESCRIPTION_MAX_LENGTH`.

## Auteur public (`author`) sur la ressource `medias`

Toutes les ressources `medias` retournées par les listings exposent un
sous-objet `author` contenant le sous-ensemble strictement PUBLIC de
l'utilisateur propriétaire. L'ancien attribut plat `userId` est retiré :
les clients lisent désormais `author.id` (même UUID).

```json
"author": {
  "id":           "0193c1…",
  "username":     "havoc",
  "nickname":     "Havoc",
  "displayName":  "Havoc",
  "bio":          "Photographe nomade",
  "avatarUrl":    "https://hexatrip-static.dev.com/users/01/93/01..c1/avatar.webp",
  "hasAvatar":    true,
  "qrcodeUrl":    "https://hexatrip.dev.com/qrcode/havoc.png?v=1812345678",
  "isVerified":   true,
  "level":        7,
  "levelProgress": 42.50,
  "displayTitle": "Touriste expert"
}
```

> `qrcodeUrl` : URL publique du QR code de profil (PNG) encodant `/@{username}`.
> Le cache-buster `?v=<timestamp>` (issu de `avatar_updated_at`) n'est présent
> que si l'utilisateur a un avatar — le QR l'embarque en son centre.

- Exposé sur les 8 endpoints qui renvoient des ressources `medias` :
  `/api/media/nearby`, `/api/media/in-bounds`, `/api/users/{userId}/media`,
  `/api/users/me/media`, `/api/users/{userId}/likes`,
  `/api/users/{userId}/dislikes`, `/api/users/me/likes`,
  `/api/users/me/dislikes`, `/api/users/me/bookmarks`, et la réponse de
  `POST /api/users/me/media` (upload).
- **N+1 évité** : chaque action batch-load les auteurs (uniques) du lot
  via `UserRepository::findManyByIds()` — une seule SELECT par page.
- Champs strictement publics : pas de `email`, `name`, `firstname`,
  `sex`, `birthdate`, `birthplaceCityId`, `status`, `confirmedAt`,
  `bannedUntil`, `joinedAt`, `experience`. Pour la fiche complète,
  utiliser les endpoints `/api/users/{userId}/*`.
- `displayTitle` est omis quand le catalogue de titres est vide (même
  contrat que `UserResourceSerializer`).
- **Breaking change** : l'attribut `userId` (plat) sur la ressource a
  été retiré. Les clients qui linkaient le profil via `media.userId`
  doivent désormais lire `media.author.id`.

Implémentation : [PublicUserSummarySerializer](../src/Http/Serializer/PublicUserSummarySerializer.php),
injecté dans [MediaResourceSerializer](../src/Http/Serializer/MediaResourceSerializer.php).

## Blocs géographiques (`city`, `subregion`, `region`, `country`)

Toutes les ressources `medias` retournées par les listings exposent jusqu'à
4 blocs hiérarchiques inline, résolus à partir des identifiants stockés
sur la `Media` (`cityId` UUID, `subregionId`/`regionId`/`countryId` codes
ISO 3166) contre les index Meilisearch correspondants. Chaque bloc est
**indépendamment nullable** : un media peut avoir un `country` mais pas
de `city` (EXIF GPS hors d'un polygone connu) et vice-versa.

```json
"city": {
  "id":          "67104949-52b7-11f1-96d5-00155dda08de",
  "name":        "Annecy",
  "slug":        "annecy",
  "latitude":    45.8992,
  "longitude":   6.1294,
  "countryId":   "fr",
  "regionId":    "fr-ara",
  "subregionId": "fr-74"
},
"subregion": { "id": "fr-74", "name": "…", "slug": "…", "regionId": "fr-ara", "countryId": "fr" },
"region":    { "id": "fr-ara", "name": "Auvergne-Rhône-Alpes", "slug": "…", "countryId": "fr" },
"country":   { "id": "fr", "name": "France", "slug": "france", "alpha3": "FRA", "numeric": "250" }
```

- Exposé sur les mêmes 8 endpoints que le bloc `author` :
  `/api/media/nearby`, `/api/media/in-bounds`, `/api/users/{userId}/media`,
  `/api/users/me/media`, `/api/users/{userId}/likes`/`dislikes`,
  `/api/users/me/likes`/`dislikes`, `/api/users/me/bookmarks`,
  `/api/social-feeds/{code}`, plus la réponse de `POST /api/users/me/media`.
- **N+1 évité** : un seul appel batch par index Meili et par page (4 calls
  total) via [MediaLocationBlocksLoader](../src/Domain/Media/MediaLocationBlocksLoader.php) ;
  resolvers individuels (`CitySummaryResolver`, `SubregionSummaryResolver`,
  `RegionSummaryResolver`, `CountrySummaryResolver`) avec cache request-scoped.
- L'identifiant clé est l'UUID dashé lowercase pour `city.id`, le code ISO
  3166 lowercase pour `subregion`/`region`/`country.id`.
- Index `subregions` partiellement couvert (~99 docs en prod) : la majorité
  des cities pointent sur un code subregion absent → `subregion: null`. Pas
  un bug, question de couverture data.
- Quand AUCUN bloc n'a été batch-loadé (chemin legacy / anonyme), les 4
  clés sont OMISES de la réponse — le wire reste rétrocompatible.

## Compteurs de profil (views + impressions)

Le même pipeline est appliqué côté utilisateurs. Toute ressource `users`
qui inclut son `stats` expose désormais :

```json
"viewsCount":       284,
"impressionsCount": 1576
```

- **view** = un appel à `GET /users/{userId}/{following,followers,media,likes,dislikes,badges}`.
  Le middleware `UserProfileViewMiddleware` (wiré sur ces 6 sous-routes,
  derrière `OptionalAuthenticationMiddleware`) lit `{userId}` sur la
  route matchée et compte un view. Déduplication par viewer sur une
  fenêtre glissante de `USER_COUNTERS_VIEW_DEDUP_WINDOW_SECONDS` (1h par
  défaut). **Self-view filtré** : un utilisateur qui consulte son propre
  profil ne s'incrémente pas.
- **impression** = un user apparaît dans la réponse de
  `GET /api/users/search`. Chaque entrée de la collection prend `+1`.
  Pas de dedup. **Self-impression filtré**.
- Les bots détectés via `jenssegers/agent` sont ignorés
  (`USER_COUNTERS_IGNORE_BOTS=true`).
- Les compteurs sont alimentés par un worker
  (`bin/user-counters-flush.php`, tick
  `USER_COUNTERS_FLUSH_TICK_SECONDS`) : ils peuvent retarder l'activité
  réelle d'un tick avant d'apparaître sur la ressource. Le worker draine
  `user_counter_event`, UPDATE `user_stats.num_views` /
  `user_stats.num_impressions` (additionnels, jamais set), puis UPSERT
  `user_view_daily` pour le jour courant.

`user_stats` reste **trigger-maintained** pour les autres compteurs
(`num_user_follower`, `num_user_followed`, `num_media`, `num_album`,
`num_comments`) — Hydrogen n'écrit que `num_views` / `num_impressions` et
exclusivement via `UserStatsCounterRepository::bumpMany()` (UPDATE
additionnel, pas d'UPSERT — la ligne `user_stats` est provisionnée à la
création du compte).

## Ressource `mediaReactions`

L'écriture (`PUT`) renvoie une ressource composite typée
`mediaReactions`, avec un id composite `mediaUuid:userUuid` :

```json
{
  "jsonapi": { "version": "1.1" },
  "data": {
    "type": "mediaReactions",
    "id":   "0193b2…:0193c1…",
    "attributes": {
      "mediaId":   "0193b2…",
      "userId":    "0193c1…",
      "value":     "like",
      "createdAt": "2026-06-08T14:21:03+00:00"
    }
  }
}
```

---

## `PUT /api/users/me/media/{mediaId}/reaction`

Pose **ou** modifie la réaction du viewer sur le média ciblé. Idempotent
si la même valeur est repostée. Un flip `like ↔ dislike` préserve le
`created_at` original (utile pour la stabilité des curseurs de listing).

- **Auth** : requise (Bearer)
- **Action** : [UpsertMediaReactionAction](../src/Http/Action/Api/Media/Reaction/UpsertMediaReactionAction.php)
- **Body** : accepté en JSON plat **ou** JSON:API.
  - Plat : `{"value": "like"}` (ou `"dislike"`)
  - JSON:API : `{"data": {"attributes": {"value": "like"}}}`
- **Réponses** :
  - `200 OK` + ressource `mediaReactions` (voir plus haut). Aucun
    distinguo entre création et flip — la ressource finale fait foi.
  - `400 Invalid body` : JSON malformé.
  - `403 Self reaction forbidden` : tentative d'auto-réaction.
  - `403 Account not confirmed` / `Account banned` : l'utilisateur n'est
    pas habilité à interagir.
  - `404 Media not found` : `mediaId` inconnu (UUID inexistant).
  - `422 Invalid value` : `value` absent ou pas dans `{"like","dislike"}`.
  - `422 Invalid media id` : `mediaId` n'est pas un UUID.

- **Effets de bord** :
  - `media_stats.likes_count` / `dislikes_count` mis à jour par trigger.
  - Document Meilisearch ré-indexé (best-effort).
  - **Notification owner** si et seulement si c'est la **première** like
    de ce viewer sur ce média (dedup serveur 5 min + dedup_key
    `media.reaction:<mediaHex>:<actorHex>` pour les ré-likes ultérieurs).

---

## `DELETE /api/users/me/media/{mediaId}/reaction`

Retire la réaction du viewer sur le média (peu importe sa valeur
courante). Idempotent : si aucune réaction n'existait, renvoie quand
même `204`.

- **Auth** : requise (Bearer)
- **Action** : [DeleteMediaReactionAction](../src/Http/Action/Api/Media/Reaction/DeleteMediaReactionAction.php)
- **Réponses** :
  - `204 No Content` — succès, même si aucune réaction préexistante.
  - `403 Account not confirmed` / `Account banned`.
  - `422 Invalid media id` : `mediaId` n'est pas un UUID.

Pas de `404` quand le média n'existe pas : on traite la requête comme un
unreact d'un état déjà vide (idempotent). Le re-index Meilisearch n'est
émis que si une ligne a effectivement été supprimée.

---

## `GET /api/media/{mediaId}/likes`

Liste **publique** des utilisateurs ayant liké le média ciblé, **réaction
la plus récente d'abord**. Pagination keyset sur
`(reaction.created_at DESC, user_id DESC)`.

- **Auth** : optionnelle (soft). Un Bearer token actif permet de remplir
  `isFollowedByMe` sur les ressources `users` retournées.
- **Action** : [ListMediaLikersAction](../src/Http/Action/Api/Media/Reaction/ListMediaLikersAction.php)
- **Query** : `cursor` (next), `before` (prev), `limit` (1..100, défaut 20).
- **Réponses** :
  - `200 OK` + collection JSON:API `users` (vide si personne n'a liké).
    `meta.total` = `COUNT(*)` exact (`media_reaction WHERE media_id = ? AND value = 'like'`).
  - `400 Invalid cursor`
  - `404 Media not found`
  - `422 Invalid media id`

---

## `GET /api/media/{mediaId}/dislikes`

Identique à `/likes` mais filtre sur `value = 'dislike'`.
[ListMediaDislikersAction](../src/Http/Action/Api/Media/Reaction/ListMediaDislikersAction.php).

---

## `GET /api/users/me/likes`

Liste les médias **publiés** que le viewer a likés, **réaction la plus
récente d'abord**. Les médias dépubliés a posteriori sont silencieusement
retirés de la collection (pas de 404, ils ressortiront si la
republication a lieu). Pagination keyset sur la `created_at` de la
réaction (pas du média).

- **Auth** : requise (Bearer)
- **Action** : [ListMyLikedMediaAction](../src/Http/Action/Api/Users/Reaction/ListMyLikedMediaAction.php)
- **Query** : `cursor`, `before`, `limit` (1..50, défaut 20).
- **Attribut additionnel** sur chaque ressource `medias` :
  - `reactedAt` : ISO-8601 de la date à laquelle le viewer a posé la
    réaction (utile pour un rendu "liké il y a 3 jours").
- **Réponses** :
  - `200 OK` + collection `medias` (peut être vide).
    `meta.total` = `COUNT(*)` exact des likes posés par le viewer.
  - `400 Invalid cursor`

---

## `GET /api/users/me/dislikes`

Idem `/me/likes` mais filtre sur les dislikes du viewer.
[ListMyDislikedMediaAction](../src/Http/Action/Api/Users/Reaction/ListMyDislikedMediaAction.php).

---

## `GET /api/users/{userId}/likes`

Variante **publique** : liste les médias publiés qu'un utilisateur tiers
a likés. Mêmes garanties que `/me/likes` (médias dépubliés masqués).

- **Auth** : optionnelle (soft) — permet de remplir `viewerReaction` sur
  les ressources `medias` retournées si le viewer est connecté.
- **Action** : [ListUserLikedMediaAction](../src/Http/Action/Api/Users/Reaction/ListUserLikedMediaAction.php)
- **Réponses** :
  - `200 OK` + collection `medias`.
  - `404 User not found` si `userId` n'existe pas.
  - `400 Invalid cursor`
  - `422 Invalid user id`

---

## `GET /api/users/{userId}/dislikes`

Idem `/users/{userId}/likes` mais sur les dislikes de l'utilisateur cible.
[ListUserDislikedMediaAction](../src/Http/Action/Api/Users/Reaction/ListUserDislikedMediaAction.php).

---
