Réactions (likes / dislikes)
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)
Section titled “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_keymedia.reaction:<mediaHex>:<actorHex>.
Compteurs et viewerReaction
Section titled “Compteurs et viewerReaction”Toutes les ressources medias exposent désormais 5 compteurs
dénormalisés sur media_stats :
"likesCount": 42,"dislikesCount": 3,"viewsCount": 1287,"impressionsCount": 9412,"commentsCount": 21likesCount/dislikesCountsont maintenus par triggers MySQL.commentsCountest maintenu applicativement par le service commentaires.viewsCountetimpressionsCountsont 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 deMEDIA_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/agentsont ignorés (MEDIA_COUNTERS_IGNORE_BOTS=true). - Les compteurs sont alimentés par un worker
(
bin/media-counters-flush.php, tickMEDIA_COUNTERS_FLUSH_TICK_SECONDS) : ils peuvent retarder l’activité réelle d’un tick avant d’apparaître sur la ressource.
- view = un appel à
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éactionL’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 :
| 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 ;
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.orientationcô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) :
- Migration SQL :
database/migrations/2026_06_13_160000_add_media_orientation.sql— ajoute la colonne et backfille les rows existants. - Re-push des settings Meili pour rendre
orientationfilterable :Terminal window php bin/media-meili-apply-settings.php - Full-reindex de l’index
media_devpour propager le champorientationsur 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
nullquand 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
descriptiondu multipartPOST /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).
- À l’upload : champ
- 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 deavatar_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 dePOST /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}/*. displayTitleest omis quand le catalogue de titres est vide (même contrat queUserResourceSerializer).- Breaking change : l’attribut
userId(plat) sur la ressource a été retiré. Les clients qui linkaient le profil viamedia.userIddoivent désormais liremedia.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 dePOST /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 poursubregion/region/country.id. - Index
subregionspartiellement 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)
Section titled “Compteurs de profil (views + impressions)”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 middlewareUserProfileViewMiddleware(wiré sur ces 6 sous-routes, derrièreOptionalAuthenticationMiddleware) lit{userId}sur la route matchée et compte un view. Déduplication par viewer sur une fenêtre glissante deUSER_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/agentsont ignorés (USER_COUNTERS_IGNORE_BOTS=true). - Les compteurs sont alimentés par un worker
(
bin/user-counters-flush.php, tickUSER_COUNTERS_FLUSH_TICK_SECONDS) : ils peuvent retarder l’activité réelle d’un tick avant d’apparaître sur la ressource. Le worker draineuser_counter_event, UPDATEuser_stats.num_views/user_stats.num_impressions(additionnels, jamais set), puis UPSERTuser_view_dailypour 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
Section titled “Ressource mediaReactions”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"}}}
- Plat :
-
Réponses :
200 OK+ ressourcemediaReactions(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:mediaIdinconnu (UUID inexistant).422 Invalid value:valueabsent ou pas dans{"like","dislike"}.422 Invalid media id:mediaIdn’est pas un UUID.
-
Effets de bord :
media_stats.likes_count/dislikes_countmis à 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:mediaIdn’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
Section titled “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
isFollowedByMesur les ressourcesusersretournées. - Action : ListMediaLikersAction
- Query :
cursor(next),before(prev),limit(1..100, défaut 20). - Réponses :
200 OK+ collection JSON:APIusers(vide si personne n’a liké).meta.total=COUNT(*)exact (media_reaction WHERE media_id = ? AND value = 'like').400 Invalid cursor404 Media not found422 Invalid media id
GET /api/media/{mediaId}/dislikes
Section titled “GET /api/media/{mediaId}/dislikes”Identique à /likes mais filtre sur value = 'dislike'.
ListMediaDislikersAction.
GET /api/users/me/likes
Section titled “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
- 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+ collectionmedias(peut être vide).meta.total=COUNT(*)exact des likes posés par le viewer.400 Invalid cursor
GET /api/users/me/dislikes
Section titled “GET /api/users/me/dislikes”Idem /me/likes mais filtre sur les dislikes du viewer.
ListMyDislikedMediaAction.
GET /api/users/{userId}/likes
Section titled “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
viewerReactionsur les ressourcesmediasretournées si le viewer est connecté. - Action : ListUserLikedMediaAction
- Réponses :
200 OK+ collectionmedias.404 User not foundsiuserIdn’existe pas.400 Invalid cursor422 Invalid user id
GET /api/users/{userId}/dislikes
Section titled “GET /api/users/{userId}/dislikes”Idem /users/{userId}/likes mais sur les dislikes de l’utilisateur cible.
ListUserDislikedMediaAction.