Skip to content

Réactions (likes / dislikes)

Un utilisateur authentifié peut liker ou disliker un média (mais pas le sien403). 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.

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

Toutes les ressources medias exposent désormais 5 compteurs dénormalisés sur media_stats :

"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) :

"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

Section titled “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 :

ValeurRègleCas typique
landscapewidth > height et width/height < 2.04:3, 16:9, photos kit
portraitheight > widthphotos mobiles, stories
squarewidth == height (ou dimensions inconnues)crop 1:1, fallback
panoramawidth > height et width/height >= 2.0panoramas stitchés, 18:9+

Le seuil panorama est codé en dur dans MediaOrientationResolver::PANORAMA_RATIO_THRESHOLD ; 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 :
    Terminal window
    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.

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 :

"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) :

  • à l’upload, quand l’EXIF porte un couple GPS (MediaUploadService) ;
  • 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).

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 :
    Terminal window
    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

Section titled “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 — 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

Section titled “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).

"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, injecté dans MediaResourceSerializer.

Blocs géographiques (city, subregion, region, country)

Section titled “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.

"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 ; 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.

Le même pipeline est appliqué côté utilisateurs. Toute ressource users qui inclut son stats expose désormais :

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

L’écriture (PUT) renvoie une ressource composite typée mediaReactions, avec un id composite mediaUuid:userUuid :

{
"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

Section titled “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

  • 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

Section titled “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
  • 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.


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
  • 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

Identique à /likes mais filtre sur value = 'dislike'. ListMediaDislikersAction.


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
  • 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

Idem /me/likes mais filtre sur les dislikes du viewer. ListMyDislikedMediaAction.


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
  • Réponses :
    • 200 OK + collection medias.
    • 404 User not found si userId n’existe pas.
    • 400 Invalid cursor
    • 422 Invalid user id

Idem /users/{userId}/likes mais sur les dislikes de l’utilisateur cible. ListUserDislikedMediaAction.