Skip to content

Photos / médias

Pipeline de gestion des photos uploadées par les utilisateurs confirmés. Les images vivent dans hxa.media (1 ligne = 1 photo), accompagnées de trois tables annexes sur la connexion back-office (hxa_bo) :

  • media_meta : mime_type, size, width, height, brand, model
  • media_exif : blob JSON des sections EXIF/GPS/IFD0
  • media_perceptual_hash : pHash 16 hex éclaté en 4 shards CHAR(4) pour rendre la recherche de doublons indexable (filtre MySQL grossier, puis Hamming complet en PHP).

Le fichier WebP canonique et son compagnon blurhash 16 px sont écrits sous MEDIA_STORAGE_PATH ; l’original (JPEG/PNG/HEIC/…) est archivé intact sous MEDIA_ORIGINALS_PATH. Le chemin est ventilé sur les 3 premières paires d’octets du hex de l’UUID :

<root>/AA/BB/CC/<32-hex>.webp
<root>/AA/BB/CC/<32-hex>-blurhash.webp
<root-originals>/AA/BB/CC/<32-hex>.<ext-source>

Un job AI externe (Talend) reprend les uploads, tague la photo et flippe is_published = 1 quand elle est validée. Tant qu’is_published = 0, la photo n’est visible que pour son propriétaire (GET /api/users/me/media) et reste exclue des listings publics.


Téléverse 1 à N photos (max MEDIA_MAX_PER_REQUEST, défaut 10) pour l’utilisateur authentifié.

  • Auth : requise (Bearer) + email confirmé (confirmed_at IS NOT NULL). Un compte non confirmé reçoit 403 userNotVerified.

  • Action : UploadMediaAction

  • Body : multipart/form-data avec le champ media répété, OU media[].

    • Formats acceptés : JPEG, PNG, WebP, GIF, HEIC, HEIF.
    • Taille max par fichier : MEDIA_MAX_UPLOAD_BYTES (défaut 12 Mo).
    • Optionnel — description (string, max MEDIA_DESCRIPTION_MAX_LENGTH, défaut 1024 caractères). Texte libre rédigé par l’utilisateur, appliqué à tous les fichiers du POST (même sémantique que les hashtags — un POST = une rafale cohérente). Trimé côté serveur ; une chaîne vide ou blanche est ignorée (= aucune description). Sur dépassement, le POST entier est rejeté 422 descriptionTooLong avant la première écriture. Persistée dans media_description (table 1-1) et incluse dans l’index Meilisearch via MediaIndexService.
    • Optionnel — hashtags[] (répété, ex. hashtags[]=sunset&hashtags[]=beach) OU hashtags en CSV (hashtags=sunset,beach). Le même jeu s’applique à TOUS les fichiers du POST (les uploads en batch partagent un thème en pratique — pas de granularité par fichier en V1). Pipeline silencieux : chaque token est normalisé via HashtagNormalizer (strip #, décomposition NFD, lower, [a-z0-9_], longueur dans [MEDIA_HASHTAG_MIN_LEN, MEDIA_HASHTAG_MAX_LEN]), les slugs bannis (config/hashtag_blocklist.php) sont écartés, et le surplus au-delà de MEDIA_HASHTAGS_MAX (défaut 30) est tronqué dans l’ordre de saisie. Le set effectivement persisté est renvoyé en meta.hashtags (paires {slug, display}).
  • Pipeline (MediaUploadService), appliqué par fichier, halt-on-first-fail, un fichier mauvais n’aborte pas ses voisins :

    1. Validation pré-décode (taille, code PSR-7, non vide).
    2. Décodage Imagick + whitelist du format.
    3. pHash perceptuel + scan des hashes existants de l’utilisateur ; un candidat à distance de Hamming ≤ MEDIA_SIMILARITY_HAMMING_THRESHOLD (défaut 5) est rejeté duplicateImage.
    4. Extraction EXIF (sections EXIF/GPS/IFD0 — pas FILE/COMPUTED qui sont synthétisées par PHP). GPS → conversion rational→decimal → appel à geo.locate(lat, lng) qui renvoie city_id (cascade ville → région → pays côté SP). En présence de GPS, l’Open Location Code (Plus Code) est aussi calculé via vectorial1024/open-location-code-php et persisté sur media.open_location_code (longueur normale, 10 chiffres significatifs + séparateur, ex. 8FW4V942+JV pour Paris) ; resté null quand l’EXIF n’a pas de GPS. Sans EXIF, is_manual = 1.
    5. Auto-orient + strip complet (privacy : on conserve les EXIF dans media_exif mais on les vire du WebP livré).
    6. Resize bestfit à 2400 px max (pas d’upscale).
    7. Encodage WebP (qualité 85, method=6).
    8. Blurhash 4×3 composants → string CHAR(30) + petit WebP 16 px peint pixel par pixel pour servir de tile CSS.
    9. Écriture atomique (tmp + rename) du WebP + blurhash + original.
    10. Inserts media, media_meta, media_exif (si EXIF présent), media_perceptual_hash. Sur échec DB, les fichiers déjà écrits sont supprimés (best-effort rollback).
    11. Enqueue dans work.media_to_describe (PK media_id BINARY(16), INSERT IGNORE idempotent) pour que le worker IA out-of-band génère la description du media. L’appel est dans le bloc try protégé par le rollback fichiers : si la queue est down, on préfère échouer l’upload plutôt que de livrer un media qui ne sera jamais décrit.
    12. Best-effort push Meilisearch media_dev via MediaIndexService — le service agrège le media, sa media_description (table 1-1 sur hxa), et tout futur side-data avant d’envoyer un document complet. Toute mutation ultérieure du media (édition de description, flip is_published par l’IA, mise à jour de score…) doit appeler reindex($id) pour rester en phase avec l’index. Jamais rollback MySQL sur échec d’indexation.
    13. GamificationXP_PER_MEDIA_UPLOAD (défaut 50) est ajouté à user.experience via un UPDATE atomique, et une ligne est insérée dans user_transaction (type=1 Experience, user_emitter_id NULL car c’est une self-action) dans la même transaction DB. Le level exposé dans la ressource users est recalculé à la lecture (voir Conventions générales). Avec les valeurs par défaut, un premier upload fait passer L1 → L2.
  • Réponse 200 OK (au moins un succès, ou 422 si tous ont échoué) : collection JSON:API medias, partiel par construction. Le front corrèle chaque ressource avec son fichier source via meta.originalName. Les fichiers refusés sont en meta.errors[] (pas dans errors[] racine).

{
"data": [
{
"type": "medias",
"id": "5cc2a02b-f014-4207-9808-7229781aab14",
"attributes": {
"type": "photo",
"cityId": null,
"blurHash": "L4Aw…",
"blurhashUrl": "http://hexatrip-static.dev.com/media/5c/c2/a0/5cc2…ab14-blurhash.webp",
"url": "http://hexatrip-static.dev.com/media/5c/c2/a0/5cc2…ab14.webp",
"latitude": null,
"longitude": null,
"openLocationCode": null,
"width": 800,
"height": 600,
"orientation": "landscape",
"shotAt": "2026-06-07T12:34:56+00:00",
"isManual": true,
"isPublished": false,
"status": "pending",
"description": "Coucher de soleil sur la plage de Biarritz.",
"createdAt": "2026-06-07T12:34:56+00:00",
"updatedAt": null
},
"meta": { "originalName": "photo.jpg" }
}
],
"meta": {
"accepted": 1,
"rejected": 1,
"errors": [
{ "originalName": "dup.jpg", "code": "duplicateImage", "title": "Une image similaire existe déjà." }
],
"hashtags": [
{ "slug": "sunset", "display": "Sunset" },
{ "slug": "beach", "display": "beach" }
],
"limits": { "maxPerRequest": 10 }
}
}
  • Codes d’erreur par fichier (mapping MediaErrorCode → i18n key media.<code>) :

    • missing, uploadFailed, empty, tooLarge, tooMany
    • invalidImage, unsupportedFormat
    • duplicateImage
    • encodingFailed, blurhashFailed, storageWriteFailed
  • 422 descriptionTooLong : description dépasse MEDIA_DESCRIPTION_MAX_LENGTH. Renvoyé au format errors[] racine, avant la boucle d’upload (fast-fail global, aucun fichier n’est traité).

  • 403 userNotVerified : compte non confirmé (renvoyé au format errors[] racine, pas par-fichier).


Liste les médias de l’utilisateur authentifié, plus récent d’abord. Contrairement à l’endpoint public, retourne aussi les photos is_published = 0 (en attente de validation par le pipeline AI). C’est ici que le front sonde l’attribut status pour suivre l’avancement du traitement de chaque upload (voir ci-dessous).

  • Auth : requise (Bearer)
  • Action : ListMyMediaAction
  • Query :
    • limit (int, 1..50, défaut 20)
    • cursor (opaque base64url) — page suivante (rows plus anciennes que le curseur)
    • before (opaque base64url) — page précédente (rows plus récentes que le curseur) ; gagne sur cursor si les deux sont fournis
    • Format du curseur : base64url("<unix-ts>.<uuid-hex>") — cursor partagé avec les autres listings paginés (notifs, follows).
  • Réponse 200 OK : collection JSON:API medias, ordre (created_at DESC, id DESC), comparaison de tuple côté SQL pour gérer les égalités de timestamp. Navigation via l’objet links racine (self/first/prev/next).
{
"data": [ /* …ressources medias… */ ],
"links": {
"self": "https://api.example/api/users/me/media?limit=20",
"first": "https://api.example/api/users/me/media?limit=20",
"prev": null,
"next": "https://api.example/api/users/me/media?cursor=MTcxMjM0…&limit=20"
},
"meta": { "limit": 20, "total": 47 }
}
  • meta.total : nombre exact de médias de l’utilisateur authentifié (publiés ET non-publiés), COUNT(*) indexé sur user_id.
  • 400 Invalid cursor si cursor ou before est mal formé.

Supprime définitivement un média possédé par l’utilisateur authentifié.

  • Auth : requise (Bearer)

  • Action : DeleteMyMediaAction

  • Effet (MediaDeleteService) :

    1. Unlink du WebP publié + blurhash WebP.
    2. Unlink de l’original archivé (glob "<hex>.*" — l’extension d’origine n’est pas stockée séparément, mais l’archive ne contient qu’un seul fichier portant ce hex).
    3. Delete dans media_meta, media_exif, media_perceptual_hash.
    4. Delete dans hxa.media.
    5. Best-effort delete du document Meilisearch.

    Le cache Glide (MEDIA_CACHE_PATH) n’est pas purgé : la source étant absente, Glide retournera 404 sur la prochaine requête et l’ops vide le cache hors-bande.

  • Réponses :

    • 204 No Content : suppression effectuée.
    • 403 forbidden : le média existe mais appartient à un autre user.
    • 404 notFound : aucun média avec cet id (ou déjà supprimé — le 2e appel sur le même id renvoie 404, ce n’est pas idempotent au sens HTTP strict).
    • 422 : mediaId n’est pas un UUID.

PUT /api/users/me/media/{mediaId}/description

Section titled “PUT /api/users/me/media/{mediaId}/description”

Met à jour (ou crée) la description libre d’un média possédé par l’utilisateur authentifié. Texte simple, max MEDIA_DESCRIPTION_MAX_LENGTH caractères (défaut 1024).

  • Auth : requise (Bearer).
  • Action : SetMediaDescriptionAction.
  • Body : application/json
    { "description": "Coucher de soleil sur la plage de Biarritz." }
    • Le serveur trime la valeur avant validation.
    • Une chaîne vide ou exclusivement blanche est traitée comme une suppression implicite (= équivalente à un DELETE /api/users/me/media/{mediaId}/description) : la ligne media_description est supprimée et le document Meilisearch est réindexé sans description.
  • Effet :
    1. Upsert dans media_description (PK media_id, table 1-1).
    2. Réindexation Meilisearch via MediaIndexService::reindex pour que la description soit visible côté recherche full-text.
  • Réponses :
    • 204 No Content : description écrite (ou supprimée si vide).
    • 403 forbidden : le média existe mais appartient à un autre user.
    • 404 notFound : aucun média avec cet id.
    • 422 descriptionTooLong : longueur > MEDIA_DESCRIPTION_MAX_LENGTH. Source-pointer /data/attributes/description.
    • 422 : mediaId n’est pas un UUID, ou body JSON invalide.

DELETE /api/users/me/media/{mediaId}/description

Section titled “DELETE /api/users/me/media/{mediaId}/description”

Supprime la description d’un média possédé par l’utilisateur authentifié. Idempotent : renvoie 204 même si aucune description n’était enregistrée (pas de 404 au 2ᵉ appel).

  • Auth : requise (Bearer).
  • Action : DeleteMediaDescriptionAction.
  • Effet :
    1. Delete de la ligne media_description (no-op si absente).
    2. Réindexation Meilisearch (description = null dans le document).
  • Réponses :
    • 204 No Content : ligne supprimée ou déjà absente.
    • 403 forbidden : le média existe mais appartient à un autre user.
    • 404 notFound : aucun média avec cet id (la garde owner-only prévaut sur l’idempotence — un id inexistant reste un 404).
    • 422 : mediaId n’est pas un UUID.

Liste publique des médias d’un utilisateur — restreinte à is_published = 1 (les uploads en attente AI restent invisibles aux autres utilisateurs).

  • Auth : aucune
  • Action : ListUserMediaAction
  • Query : identique à GET /api/users/me/media (limit, cursor, before).
  • Réponses :
    • 200 OK + collection JSON:API medias (vide si l’user n’a aucune photo publiée). meta.total donne le nombre exact de médias publiés de l’utilisateur ciblé (COUNT(*) WHERE user_id = ? AND is_published = 1).
    • 404 User not found si l’id n’existe pas.
    • 422 Invalid user id si userId n’est pas un UUID.

Découverte géographique : retourne les médias publiés situés dans un rayon distance (en mètres) autour d’un point GPS, triés du plus proche au plus éloigné. Adossé à Meilisearch (filtre _geoRadius, tri _geoPoint:asc).

  • Auth : aucune.
  • Action : SearchNearbyMediaAction.
  • Query (obligatoires) :
    • lat : float dans [-90, 90]
    • lng : float dans [-180, 180]
    • distance : entier positif (mètres), borné par MEDIA_NEARBY_MAX_DISTANCE_METERS (défaut 100 000 m = 100 km).
  • Query (optionnels) :
    • q : full-text sur name + description (vide = pur browse géo)
    • hashtags : répété (hashtags[]=sunset&hashtags[]=beach) OU CSV (hashtags=sunset,beach). Les tokens passent par HashtagNormalizer#Sunset! matche bien le facet sunset. OR entre les slugs : un média est retenu s’il porte AU MOINS UN des hashtags. La liste effectivement appliquée est renvoyée dans meta.hashtags.
    • orientation : landscape, portrait, square ou panorama (whitelist stricte ; toute autre valeur → 422). Filtre exact sur le facet orientation du document Meili. Absent = toutes orientations. La valeur effectivement appliquée est renvoyée dans meta.orientation (ou null).
    • limit : 1..50 (défaut 20)
    • offset : 0+ (défaut 0)
  • Filtrage Meilisearch : _geoRadius(lat, lng, distance) AND is_published = true (+ AND hashtags IN [...] et/ou AND orientation = '...' si fournis). Une seconde garantie est appliquée côté MySQL au moment de la ré-hydratation (publishedOnly: true) pour neutraliser un éventuel document Meili obsolète.
  • Pipeline : Meilisearch renvoie des id ordonnés par distance croissante, Hydrogen ré-hydrate les entités Media depuis MySQL via MediaRepository::findManyByIds() → mêmes ressources medias que les autres endpoints, avec un attribut supplémentaire :
    • attributes.distanceMeters : distance en mètres au point de référence (lu depuis le _geoDistance que Meili calcule lors du tri par _geoPoint).
{
"jsonapi": { "version": "1.1" },
"data": [
{
"type": "medias",
"id": "0193…",
"attributes": {
"name": "Pont du Gard",
"url": "http://hexatrip-static.dev.com/media/…",
"latitude": 43.9476,
"longitude": 4.5354,
"distanceMeters": 248.7,
"...": ""
}
}
],
"links": {
"self": "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20",
"first": "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20",
"prev": null,
"next": "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20&offset=20",
"last": "https://api.example/api/media/nearby?distance=2000&lat=43.95&lng=4.54&limit=20&offset=80"
},
"meta": {
"totalHits": 95,
"limit": 20,
"offset": 0,
"center": { "lat": 43.95, "lng": 4.54 },
"distance": 2000,
"query": ""
}
}
  • Erreurs :

    • 422 : lat/lng/distance manquant, non numérique, hors bornes, ou distance > MEDIA_NEARBY_MAX_DISTANCE_METERS.
    • 503 Search backend unavailable : Meilisearch indisponible ou index mal configuré (voir prérequis ci-dessous) — l’erreur API est propagée dans errors[].detail.
  • Prérequis index Meilisearch (one-shot, à appliquer côté ops sur l’index MEILISEARCH_MEDIA_INDEX) :

    • filterableAttributes doit contenir _geo ET is_published
    • sortableAttributes doit contenir _geo
    • Exemple (curl) :
      Terminal window
      curl -X PATCH "$MEILI/indexes/$INDEX/settings" -H "Authorization: Bearer $KEY" \
      -H "Content-Type: application/json" \
      -d '{"filterableAttributes":["_geo","is_published","user_id"],"sortableAttributes":["_geo","shot_at","created_at"]}'
    • Les documents poussés par MeilisearchMediaSync émettent automatiquement l’objet _geo: {lat, lng} quand les deux coordonnées sont connues côté domaine ; un média sans GPS n’apparaîtra simplement pas dans les résultats de cet endpoint.

Découverte par rectangle géographique (bounding box). Retourne les médias publiés dont la position GPS tombe dans le rectangle décrit par les quatre coins, sans tri par distance (le rectangle n’a pas de centre canonique — l’ordre suit la pertinence de q ou l’ordre d’index). Pensé pour des UI carto qui pan/zoom et veulent peupler le viewport courant (équivalent de map.getBounds() côté Leaflet/Mapbox).

  • Auth : optionnelle. Quand un Bearer token est présent, les viewerReaction sont peuplées (sinon null).
  • Action : SearchMediaInBoundsAction.
  • Query (obligatoires) :
    • north : float dans (-90, 90]
    • south : float dans [-90, 90), strictement < north
    • east : float dans [-180, 180]
    • west : float dans [-180, 180], <= east (le wrap au méridien 180 n’est PAS supporté — le client doit envoyer deux requêtes s’il en a besoin)
  • Query (optionnels) :
    • q : full-text sur name + description
    • hashtags : répété OU CSV (mêmes règles que /media/nearby).
    • orientation : landscape, portrait, square ou panorama (mêmes règles que /media/nearby).
    • limit : 1..50 (défaut 20)
    • offset : 0+ (défaut 0)
  • Filtrage Meilisearch : _geoBoundingBox([north, east], [south, west]) AND is_published = true (+ AND hashtags IN [...] et/ou AND orientation = '...' si fournis). Convention Meilisearch : [top-right_lat, top-right_lng], [bottom-left_lat, bottom-left_lng]. Comme /media/nearby, une seconde garantie est appliquée côté MySQL au moment de la ré-hydratation (publishedOnly: true).
{
"jsonapi": { "version": "1.1" },
"data": [
{ "type": "medias", "id": "0193…", "attributes": { "name": "", "latitude": 43.9476, "longitude": 4.5354, "...": "" } }
],
"links": {
"self": "https://api.example/api/media/in-bounds?north=44.0&south=43.9&east=4.6&west=4.5&limit=20",
"first": "",
"prev": null,
"next": "",
"last": ""
},
"meta": {
"totalHits": 137,
"limit": 20,
"offset": 0,
"bbox": { "north": 44.0, "south": 43.9, "east": 4.6, "west": 4.5 },
"query": ""
}
}
  • Erreurs :

    • 422 : un des quatre paramètres manquant, non numérique, hors bornes ; south >= north ; west > east.
    • 503 Search backend unavailable : Meilisearch indisponible ou index mal configuré.
  • Prérequis index Meilisearch : identiques à /media/nearby côté filtres (_geo et is_published dans filterableAttributes). _geoBoundingBox n’a PAS besoin que _geo soit dans sortableAttributes (aucun tri par distance ici), mais le partager avec /media/nearby ne pose aucun problème.


Recommandation personnalisée « médias près de chez vous ». Variante de /media/nearbyle centre géographique et le filtre thématique sont dérivés du viewer plutôt qu’épelés dans l’URL. Tri du plus proche au plus éloigné (_geoPoint:asc).

  • Auth : optionnelle. Connecté = centre déduit du profil + personnalisation par topics ; anonyme = lat+lng obligatoires, pas de personnalisation.
  • Action : RecommendedNearbyMediaAction.
  • Résolution du centre (par priorité) :
    1. lat + lng explicites (fix GPS du téléphone) — priment toujours. Fournis ensemble ou pas du tout.
    2. sinon, la ville de naissance du viewer (user.birthplaceCityId) résolue en coordonnées via l’index Meili cities (CitySummaryResolver).
    • Aucun des deux → 422 (impossible de recommander « près d’ici » sans « ici »).
  • Personnalisation par topics (personalize, défaut on) : quand le viewer suit des topics ET que ces topics sont mappés vers des slugs de hashtags dans la table topic_hashtag, les résultats sont restreints aux médias portant au moins un de ces slugs (hashtags IN [...]). Sans viewer / sans mapping / personalize=0 → filtre omis, l’endpoint dégrade proprement vers une simple recherche par rayon. Les slugs effectivement appliqués sont renvoyés dans meta.topics.
  • Query (optionnels) :
    • lat, lng : décimaux, ensemble ou pas du tout (override du fallback ville de naissance).
    • distance : entier positif (mètres), défaut et plafond = MEDIA_NEARBY_MAX_DISTANCE_METERS (100 km).
    • personalize : 0/false/no/off désactive le filtre topics→hashtags.
    • limit : 1..50 (défaut 20) ; offset : 0+ (défaut 0).
  • Filtrage Meilisearch : _geoRadius(lat, lng, distance) AND is_published = true (+ AND hashtags IN [...] si personnalisation active). Ré-hydratation MySQL publishedOnly: true comme /media/nearby. Chaque ressource porte attributes.distanceMeters.
  • meta : totalHits, limit, offset, center: { lat, lng, source } (source = explicit | birthplace), distance, personalize, topics: [...].
  • Erreurs :
    • 422 Invalid center : lat sans lng (ou inverse).
    • 422 No usable location : aucun lat/lng et pas de ville de naissance exploitable.
    • 422 : lat/lng hors bornes, distance non positif ou > MEDIA_NEARBY_MAX_DISTANCE_METERS.
    • 503 Search backend unavailable : Meilisearch indisponible ou index mal configuré.
  • Prérequis index Meilisearch : identiques à /media/nearby (_geo + is_published filterables, _geo sortable). La personnalisation requiert l’attribut hashtags filterable.

« Tendances », éventuellement cadrées sur un pays. Modèle de ranking volontairement simple (pas de job nocturne) : médias publiés créés dans une fenêtre récente, triés par likes_count décroissant (tie-break created_at desc). Le champ score (qualité IA) n’est pas utilisé : il mesure la qualité intrinsèque, pas l’engagement, et n’est pas sortable.

  • Auth : optionnelle. Connecté = fallback pays via ville de naissance + personnalisation topics ; anonyme = ?country explicite ou mondial.
  • Action : TrendingMediaAction.
  • Résolution du pays (par priorité) :
    1. country explicite (ISO 3166-1 alpha-2, insensible à la casse).
    2. sinon, le pays de la ville de naissance du viewer.
    3. sinon, mondial (aucun filtre pays).
  • Personnalisation par topics : identique à /media/recommended/nearby (personalize, défaut on ; meta.topics).
  • Query (optionnels) :
    • country : ISO alpha-2. Vide → chaîne de fallback ci-dessus.
    • windowDays : entier 1..MEDIA_TRENDING_MAX_WINDOW_DAYS (défaut 365), défaut MEDIA_TRENDING_WINDOW_DAYS (30). Fenêtre de fraîcheur (created_at >= now - windowDays * 86400).
    • personalize : 0/false/no/off désactive le filtre topics→hashtags.
    • limit : 1..50 (défaut 20) ; offset : 0+ (défaut 0).
  • Filtrage Meilisearch : is_published = true AND created_at >= <since> (+ AND country_id = 'XX' si pays résolu, + AND hashtags IN [...] si personnalisation active). Tri ["likes_count:desc", "created_at:desc"]. Ré-hydratation MySQL publishedOnly: true.
  • meta : totalHits, limit, offset, country (ISO uppercase ou null), countrySource (explicit | birthplace | null), windowDays, personalize, topics: [...].
  • Erreurs :
    • 422 Invalid country : country non conforme à ISO alpha-2.
    • 422 Invalid windowDays : windowDays non entier.
    • 503 Search backend unavailable : Meilisearch indisponible ou index mal configuré.
  • Prérequis index Meilisearch : is_published, created_at, country_id et hashtags filterables ; likes_count et created_at sortables.