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,modelmedia_exif: blob JSON des sections EXIF/GPS/IFD0media_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.
POST /api/users/me/media
Section titled “POST /api/users/me/media”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çoit403 userNotVerified. -
Action : UploadMediaAction
-
Body :
multipart/form-dataavec le champmediarépété, OUmedia[].- Formats acceptés : JPEG, PNG, WebP, GIF, HEIC, HEIF.
- Taille max par fichier :
MEDIA_MAX_UPLOAD_BYTES(défaut 12 Mo). - Optionnel —
description(string, maxMEDIA_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 descriptionTooLongavant la première écriture. Persistée dansmedia_description(table 1-1) et incluse dans l’index Meilisearch viaMediaIndexService. - Optionnel —
hashtags[](répété, ex.hashtags[]=sunset&hashtags[]=beach) OUhashtagsen 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à deMEDIA_HASHTAGS_MAX(défaut 30) est tronqué dans l’ordre de saisie. Le set effectivement persisté est renvoyé enmeta.hashtags(paires{slug, display}).
-
Pipeline (MediaUploadService), appliqué par fichier, halt-on-first-fail, un fichier mauvais n’aborte pas ses voisins :
- Validation pré-décode (taille, code PSR-7, non vide).
- Décodage Imagick + whitelist du format.
- pHash perceptuel + scan des hashes existants de l’utilisateur ;
un candidat à distance de Hamming ≤
MEDIA_SIMILARITY_HAMMING_THRESHOLD(défaut 5) est rejetéduplicateImage. - 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 renvoiecity_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é surmedia.open_location_code(longueur normale, 10 chiffres significatifs + séparateur, ex.8FW4V942+JVpour Paris) ; resténullquand l’EXIF n’a pas de GPS. Sans EXIF,is_manual = 1. - Auto-orient + strip complet (privacy : on conserve les EXIF dans
media_exifmais on les vire du WebP livré). - Resize
bestfità 2400 px max (pas d’upscale). - Encodage WebP (qualité 85,
method=6). - Blurhash 4×3 composants → string
CHAR(30)+ petit WebP 16 px peint pixel par pixel pour servir de tile CSS. - Écriture atomique (
tmp + rename) du WebP + blurhash + original. - 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). - Enqueue dans
work.media_to_describe(PKmedia_id BINARY(16),INSERT IGNOREidempotent) 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. - Best-effort push Meilisearch
media_devvia MediaIndexService — le service agrège lemedia, samedia_description(table 1-1 surhxa), et tout futur side-data avant d’envoyer un document complet. Toute mutation ultérieure du media (édition de description, flipis_publishedpar l’IA, mise à jour de score…) doit appelerreindex($id)pour rester en phase avec l’index. Jamais rollback MySQL sur échec d’indexation. - Gamification —
XP_PER_MEDIA_UPLOAD(défaut 50) est ajouté àuser.experiencevia un UPDATE atomique, et une ligne est insérée dansuser_transaction(type=1 Experience,user_emitter_idNULL car c’est une self-action) dans la même transaction DB. Lelevelexposé dans la ressourceusersest 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, ou422si tous ont échoué) : collection JSON:APImedias, partiel par construction. Le front corrèle chaque ressource avec son fichier source viameta.originalName. Les fichiers refusés sont enmeta.errors[](pas danserrors[]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 keymedia.<code>) :missing,uploadFailed,empty,tooLarge,tooManyinvalidImage,unsupportedFormatduplicateImageencodingFailed,blurhashFailed,storageWriteFailed
-
422 descriptionTooLong:descriptiondépasseMEDIA_DESCRIPTION_MAX_LENGTH. Renvoyé au formaterrors[]racine, avant la boucle d’upload (fast-fail global, aucun fichier n’est traité). -
403 userNotVerified: compte non confirmé (renvoyé au formaterrors[]racine, pas par-fichier).
GET /api/users/me/media
Section titled “GET /api/users/me/media”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 surcursorsi 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:APImedias, ordre(created_at DESC, id DESC), comparaison de tuple côté SQL pour gérer les égalités de timestamp. Navigation via l’objetlinksracine (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é suruser_id.400 Invalid cursorsicursoroubeforeest mal formé.
DELETE /api/users/me/media/{mediaId}
Section titled “DELETE /api/users/me/media/{mediaId}”Supprime définitivement un média possédé par l’utilisateur authentifié.
-
Auth : requise (Bearer)
-
Action : DeleteMyMediaAction
-
Effet (MediaDeleteService) :
- Unlink du WebP publié + blurhash WebP.
- 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). - Delete dans
media_meta,media_exif,media_perceptual_hash. - Delete dans
hxa.media. - 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:mediaIdn’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 lignemedia_descriptionest supprimée et le document Meilisearch est réindexé sans description.
- Effet :
- Upsert dans
media_description(PKmedia_id, table 1-1). - Réindexation Meilisearch via MediaIndexService::reindex pour que la description soit visible côté recherche full-text.
- Upsert dans
- 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:mediaIdn’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 :
- Delete de la ligne
media_description(no-op si absente). - Réindexation Meilisearch (description =
nulldans le document).
- Delete de la ligne
- 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:mediaIdn’est pas un UUID.
GET /api/users/{userId}/media
Section titled “GET /api/users/{userId}/media”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:APImedias(vide si l’user n’a aucune photo publiée).meta.totaldonne le nombre exact de médias publiés de l’utilisateur ciblé (COUNT(*) WHERE user_id = ? AND is_published = 1).404 User not foundsi l’id n’existe pas.422 Invalid user idsiuserIdn’est pas un UUID.
GET /api/media/nearby
Section titled “GET /api/media/nearby”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é parMEDIA_NEARBY_MAX_DISTANCE_METERS(défaut 100 000 m = 100 km).
- Query (optionnels) :
q: full-text surname+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 facetsunset. 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 dansmeta.hashtags.orientation:landscape,portrait,squareoupanorama(whitelist stricte ; toute autre valeur →422). Filtre exact sur le facetorientationdu document Meili. Absent = toutes orientations. La valeur effectivement appliquée est renvoyée dansmeta.orientation(ounull).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/ouAND 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
idordonnés par distance croissante, Hydrogen ré-hydrate les entitésMediadepuis MySQL viaMediaRepository::findManyByIds()→ mêmes ressourcesmediasque les autres endpoints, avec un attribut supplémentaire :attributes.distanceMeters: distance en mètres au point de référence (lu depuis le_geoDistanceque 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/distancemanquant, non numérique, hors bornes, oudistance > 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 danserrors[].detail.
-
Prérequis index Meilisearch (one-shot, à appliquer côté ops sur l’index
MEILISEARCH_MEDIA_INDEX) :filterableAttributesdoit contenir_geoETis_publishedsortableAttributesdoit 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.
GET /api/media/in-bounds
Section titled “GET /api/media/in-bounds”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
viewerReactionsont peuplées (sinonnull). - Action : SearchMediaInBoundsAction.
- Query (obligatoires) :
north: float dans(-90, 90]south: float dans[-90, 90), strictement< northeast: 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 surname+descriptionhashtags: répété OU CSV (mêmes règles que/media/nearby).orientation:landscape,portrait,squareoupanorama(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/ouAND 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/nearbycôté filtres (_geoetis_publisheddansfilterableAttributes)._geoBoundingBoxn’a PAS besoin que_geosoit danssortableAttributes(aucun tri par distance ici), mais le partager avec/media/nearbyne pose aucun problème.
GET /api/media/recommended/nearby
Section titled “GET /api/media/recommended/nearby”Recommandation personnalisée « médias près de chez vous ». Variante de /media/nearby où le 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+lngobligatoires, pas de personnalisation. - Action : RecommendedNearbyMediaAction.
- Résolution du centre (par priorité) :
lat+lngexplicites (fix GPS du téléphone) — priment toujours. Fournis ensemble ou pas du tout.- sinon, la ville de naissance du viewer (
user.birthplaceCityId) résolue en coordonnées via l’index Meilicities(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 tabletopic_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 dansmeta.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/offdé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 MySQLpublishedOnly: truecomme/media/nearby. Chaque ressource porteattributes.distanceMeters. - meta :
totalHits,limit,offset,center: { lat, lng, source }(source=explicit|birthplace),distance,personalize,topics: [...]. - Erreurs :
422 Invalid center:latsanslng(ou inverse).422 No usable location: aucunlat/lnget pas de ville de naissance exploitable.422:lat/lnghors bornes,distancenon 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_publishedfilterables,_geosortable). La personnalisation requiert l’attributhashtagsfilterable.
GET /api/media/trending
Section titled “GET /api/media/trending”« 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 =
?countryexplicite ou mondial. - Action : TrendingMediaAction.
- Résolution du pays (par priorité) :
countryexplicite (ISO 3166-1 alpha-2, insensible à la casse).- sinon, le pays de la ville de naissance du viewer.
- 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éfautMEDIA_TRENDING_WINDOW_DAYS(30). Fenêtre de fraîcheur (created_at >= now - windowDays * 86400).personalize:0/false/no/offdé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 MySQLpublishedOnly: true. - meta :
totalHits,limit,offset,country(ISO uppercase ounull),countrySource(explicit|birthplace|null),windowDays,personalize,topics: [...]. - Erreurs :
422 Invalid country:countrynon conforme à ISO alpha-2.422 Invalid windowDays:windowDaysnon entier.503 Search backend unavailable: Meilisearch indisponible ou index mal configuré.
- Prérequis index Meilisearch :
is_published,created_at,country_idethashtagsfilterables ;likes_countetcreated_atsortables.