Offres
Triplet /api/offers (list paginé) / /api/offers/search (full-text + géo) / /api/offers/{id} (détail) adossé à l’index Meilisearch MEILISEARCH_OFFERS_INDEX (offers_v2 en prod comme en dev — le suffixe de version reste dans l’env pour permettre des rolls d’index blue/green sans toucher au code, qui parle toujours d’« offers » au sens logique). Catalogue ~377 000 documents ⇒ pagination obligatoire sur les listings.
Comme pour les établissements, Hydrogen n’a pas de domaine Offer côté MySQL : l’index Meilisearch est la source de vérité, alimenté par un processus externe. Pas de re-hydratation SQL, pas de cache applicatif — chaque hit Meili devient ressource JSON:API directement.
Identité côté API : le id JSON:API d’une ressource offers est le hash hex 32 caractères lowercase stocké dans le champ id du document Meili. Même contrainte de canonicité que /api/establishments/{id} : la regex de path ^[a-f0-9]{32}$ est stricte sur la casse (pas de normalisation silencieuse).
Shape attributes commune (formatter partagé) : les 3 endpoints utilisent OfferHitFormatter, qui :
- recopie tous les champs du document dans
attributes, sauf les clés Meili-internes (_geo,_geoDistance,_formatted,_matchesPosition,_rankingScore,_rankingScoreDetails) et leidbrut (passe en JSON:API id) ; - aplatit
_geo: { lat, lng }enattributes.latitude/attributes.longitude; - expose
_geoDistance(mètres) enattributes.distanceMetersquand le tri géo est actif (search en mode géo uniquement).
Champs métier exposés tels quels : name, description, url, call_price{price, currency}, position, importance, open_location_code, brand{brand_id, brand_name, brand_code}, establishment{id, name, slug}.
created_atetudated_at(sic — la faute de frappe vit dans le pipeline source) sont stockés dans l’index (gardés pour le tri whitelistsort=created_at/sort=udated_at) mais sont privés : ils ne sortent pas dansattributes— bookkeeping ETL, hors contrat public.
Pas de description Markdown ici — contrairement aux pays/régions/sous-régions, les offres n’ont pas de blurb éditorial localisé. Le champ
descriptionprovient directement du document Meili tel quel.
GET /api/offers
Section titled “GET /api/offers”Listing paginé du catalogue, sans full-text ni géo (pour ça, voir /api/offers/search). Pratique pour le tooling BO, les balayages de fraîcheur, ou pour lister toutes les offres d’un établissement donné via ?establishment=.
-
Auth : aucune.
-
Action : ListOffersAction.
-
Query :
limit(int, défaut20, plafond100).offset(int ≥ 0, défaut0).sort(string, optionnel) — whitelist :created_at/-created_at(= défaut, desc),created_at_asc,importance/-importance,importance_asc,position/-position,price/price_asc/-price/price_desc(alias surcall_price.price),udated_at/-udated_at,udated_at_asc. Valeur hors whitelist →422 Invalid sort(évite d’exposer un attribut absent desortableAttributesce qui 503erait Meili).establishment(optionnel) —[a-f0-9]{32}strict ; filtreestablishment.id = "<hex>"côté index. Casse non lowercase ⇒422 Invalid establishment id.
-
Tri par défaut :
created_at:desc. -
Réponse
200 OK:
{ "data": [ { "type": "offers", "id": "5c2f1c5b9a4d4f3e8b7a6c1d2e3f4a5b", "attributes": { "name": "Menu découverte", "description": "Entrée + plat + dessert + café", "url": "https://exemple.tld/menu", "call_price": { "price": 24.50, "currency": "EUR" }, "position": 1, "importance": 7, "open_location_code": "8FW4V75V+8Q", "brand": { "brand_id": "42", "brand_name": "Acme", "brand_code": "ACM" }, "establishment": { "id": "00003c7104994cc688663a51b89c9d40", "name": "Café des Arts", "slug": "cafe-des-arts" }, "latitude": 48.8566, "longitude": 2.3522 } } ], "links": { "self": "https://api.example/api/offers?limit=20", "first": "https://api.example/api/offers?limit=20", "prev": null, "next": "https://api.example/api/offers?limit=20&offset=20", "last": "https://api.example/api/offers?limit=20&offset=377480" }, "meta": { "totalHits": 377485, "limit": 20, "offset": 0, "count": 20, "sort": "created_at:desc" }}- Réponses d’erreur :
422 Invalid sort— valeur desorthors whitelist.422 Invalid establishment id—?establishmentmal formé (pas[a-f0-9]{32}).503 Search backend unavailable.
GET /api/offers/search
Section titled “GET /api/offers/search”Recherche d’offres par texte (?q=…), par proximité GPS (?lat=…&lng=…&distance=…), ou les deux combinés. Sémantique et erreurs strictement identiques à GET /api/establishments/search — seuls le type JSON:API et l’index Meili changent.
- Auth : aucune (endpoint public).
- Action : SearchOffersAction.
- Query : (
q,lat,lng,distance,limit,offset).limitplafonné à50(vs 100 sur/api/offers).distanceborné parOFFER_NEARBY_MAX_DISTANCE_METERS(défaut 50 km).lat/lng/distancemutuellement requis ⇒ partial input =422.
- Pipeline : pass-through des hits Meili (formatter partagé), avec
attributes.distanceMetersrempli quand le tri géo est actif. - Réponse
200 OK: ressourcesoffersavec navigationlinks.{self,first,prev,next,last}etmeta.{totalHits,limit,offset,query[,center,distance]}. Même shapeattributesque/api/offers(formatter partagé). - Réponses d’erreur : identiques à
/api/establishments/search(422pour params invalides ou incomplets,503si Meilisearch est indisponible).
Pré-requis index Meilisearch (one-shot, sans ça le mode géo 503e) :
_geodansfilterableAttributesETsortableAttributes- les champs texte recherchables dans
searchableAttributes
Index versionné :
MEILISEARCH_OFFERS_INDEX=offers_v2en prod comme en dev (contrairement àmedia_dev/users_dev). C’est volontaire : les rolls de schéma d’offres se font en créant unoffers_v3à côté, en le remplissant via le process externe, puis en flippant l’env — aucun déploiement de code requis pour bumper la version.
GET /api/offers/{id}
Section titled “GET /api/offers/{id}”Détail d’une offre par son id hex.
-
Auth : aucune.
-
Action : GetOfferAction.
-
Path :
{id}— regex stricte[a-f0-9]{32}. Pas de normalisation de casse : upper ⇒404. Le pattern de routage Slim porte déjà la regex, donc tout id mal formé n’atteint même pas l’action.
-
Résolution : filtre Meili exact
id = "<hex>"sur l’index (l’attributidest filterable) — pas de recherche full-text, le scorer pourrait surfacer un mauvais doc sur des préfixes d’id. -
Réponse
200 OK: ressource uniqueoffersavec la même shapeattributesque les listings (formatter partagé). -
Réponses d’erreur :
404 Offer not found— id mal formé (n’atteint pas l’action, le router 404e via la regex) OU id valide mais absent de l’index.503 Search backend unavailable.
POST /api/offers/{id}/clicks
Section titled “POST /api/offers/{id}/clicks”Génère un subid par clic (clickRef) sans rediriger. À utiliser quand tu rends toi-même le lien que l’affilié t’a fourni (pointant directement vers le réseau) au lieu de passer par notre redirect /go/... : tu récupères le clickRef ici puis tu le colles dans le lien affilié comme subid.
-
Auth : aucune (optionnelle : un
Bearerrattache l’utilisateur au clic ; anonyme OK). -
Action : CreateOfferClickAction.
-
Path :
{id}— offre, regex stricte[a-f0-9]{32}.
-
Body (JSON) :
{ "mediaId": "<32-hex>" }—mediaIdobligatoire (le contexte média est toujours requis). -
Effet : mint un
clickRefopaque (UUIDv7 hex), persiste une lignetracking_click(média obligatoire + utilisateur nullable + visiteur), et émet le cookie visiteur si activé. Seul leclickRefopaque est renvoyé — jamaisuserId/PII. -
Réponse
201 Created— ressource JSON:APIclicks;data.idEST leclickRef:
{ "data": { "type": "clicks", "id": "0193a1b2c3d4e5f60718293a4b5c6d7e", "attributes": { "clickRef": "0193a1b2c3d4e5f60718293a4b5c6d7e", "subidParam": "subid", "url": "https://merchant.example.com/deal/123?subid=0193a1b2c3d4e5f60718293a4b5c6d7e" } }}| Attribut | Sens |
|---|---|
clickRef | le subid opaque à insérer dans le lien affilié (= data.id) |
subidParam | nom de paramètre subid à utiliser (config TRACKING_SUBID_PARAM) ; utile si tu construis le lien côté client |
url | URL marchande de l’offre avec le subid déjà injecté (placeholder {subid} substitué, sinon ?subid= ajouté), ou null si l’offre n’a pas d’URL exploitable |
- Réponses d’erreur :
404 Offer not found— id mal formé OU offre absente de l’index.422 Invalid media id/Invalid body—mediaIdmanquant/mal formé ou body non-JSON.503 Search backend unavailable.
Exemple
curl -X POST \ -H "Content-Type: application/json" \ -d '{"mediaId":"a1b2c3d4e5f600112233445566778899"}' \ "http://hydrogen.dev.com/api/offers/0f1e2d3c4b5a69788796a5b4c3d2e1f0/clicks"Le
clickRefremonte ensuite dans le postback de conversion (POST /admin/tracking/conversions, champclickRef) et est résoluble viaGET /admin/tracking/clicks/{ref}(voirdocs/admin.md).
GET /go/offer/{id}
Section titled “GET /go/offer/{id}”Lien de suivi de clic + redirection vers le marchand. Brique du modèle d’affiliation/commission : au lieu de pointer l’offre directement vers l’URL du marchand, le front-end pointe vers cet endpoint. On compte un clic facturable (bufferisé, anti-bot) puis on renvoie un 302 vers l’URL de destination de l’offre.
-
Auth : aucune (auth optionnelle : si un
Bearerest présent, le viewer est attaché à la requête — l’attribution reste au niveau de la cible en v1). -
Action : RedirectOfferAction.
-
Hors groupe
/api: pas de middleware JSON ; succès =302brut, erreurs = JSON:API. -
Path :
{id}— regex stricte[a-f0-9]{32}(même shape canonique queGET /api/offers/{id}).
-
Résolution : filtre Meili exact
id = "<hex>", on ne récupère queid+url. -
Comptage : le clic part dans un tampon (
tracking_event) vidé en fin de requête (register_shutdown_function), drainé par le workerbin/tracking-flush.phpverstracking_stats/tracking_daily. Les bots (crawlers, dépliage de liens, User-Agent vide) sont ignorés quandTRACKING_IGNORE_BOTS=true. Aucun dédoublonnage : chaque clic réel compte (le réseau d’affiliation fait sa propre attribution). -
Conversions : remontent côté Admin via
POST /admin/tracking/conversions(voirdocs/admin.md). -
Réponse
302 Found: headerLocation= URL marchand de l’offre. Aucun corps. -
Réponses d’erreur :
404 Offer not found— id mal formé, offre absente, ou offre sans champurlexploitable.503 Search backend unavailable.
Exemple
curl -i "http://hydrogen.dev.com/go/offer/0f1e2d3c4b5a69788796a5b4c3d2e1f0"# HTTP/1.1 302 Found# Location: https://merchant.example.com/deal/123?aff=hexatripGET /go/offer/{id}/media/{mediaId}
Section titled “GET /go/offer/{id}/media/{mediaId}”Variante du lien de suivi ci-dessus avec attribution par clic (le subid d’affiliation). À utiliser quand le lien partenaire est rendu au contexte d’un média : on veut savoir non seulement que l’offre a été cliquée, mais qui a cliqué et depuis quel média.
-
Auth : aucune (optionnelle ; un
Bearerprésent rattache l’utilisateur au clic). Les clics anonymes sont gérés nativement (userIdnul). -
Action : RedirectOfferMediaAction.
-
Hors groupe
/api: succès =302brut, erreurs = JSON:API. -
Path :
{id}— offre, regex stricte[a-f0-9]{32}.{mediaId}— média, regex stricte[a-f0-9]{32}. Toujours obligatoire (segment de chemin).
-
Subid : on mint un
clickRefopaque (UUIDv7 en 32 hex), on persiste une lignetracking_clickreliantclickRef → média (obligatoire) + utilisateur (nullable) + visiteur, puis on ajoute leclickRefà l’URL marchande comme paramètresubid(nom configurable viaTRACKING_SUBID_PARAM). Le réseau renvoie ce subid dans son postback de conversion (POST /admin/tracking/conversions, champclickRef) pour une attribution précise clic → média → utilisateur. -
Confidentialité : seul le
clickRefopaque sort du système — jamaisuserIdni PII. L’identité reste résoluble côté Admin viaGET /admin/tracking/clicks/{ref}. -
Visiteur anonyme : si
TRACKING_VISITOR_COOKIE=true, un cookie longue durée (hyv, UUIDv7) corrèle les clics d’un même invité non connecté. Désactivé par défaut (consentement RGPD requis).HttpOnly,SameSite=Lax(survit au 302). -
Comptage cible : le clic facturable par cible est compté en plus, exactement comme
/go/offer/{id}(tampon bufferisé, anti-bot). -
Réponse
302 Found: headerLocation= URL marchand + subid. Émet aussi unSet-Cookievisiteur si un nouvel id est minté. -
Réponses d’erreur :
404 Offer not found/404 Media not found— id mal formé, offre absente/sansurl.503 Search backend unavailable.
-
Robustesse : un échec de persistance du clic ne casse pas la redirection (fallback sur l’URL sans subid).
Exemple
curl -i "http://hydrogen.dev.com/go/offer/0f1e2d3c4b5a69788796a5b4c3d2e1f0/media/a1b2c3d4e5f600112233445566778899"# HTTP/1.1 302 Found# Location: https://merchant.example.com/deal/123?subid=0193a1b2c3d4e5f60718293a4b5c6d7e