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.

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.