Skip to content

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 :

  1. recopie tous les champs du document dans attributes, sauf les clés Meili-internes (_geo, _geoDistance, _formatted, _matchesPosition, _rankingScore, _rankingScoreDetails) et le id brut (passe en JSON:API id) ;
  2. aplatit _geo: { lat, lng } en attributes.latitude / attributes.longitude ;
  3. expose _geoDistance (mètres) en attributes.distanceMeters quand 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_at et udated_at (sic — la faute de frappe vit dans le pipeline source) sont stockés dans l’index (gardés pour le tri whitelist sort=created_at / sort=udated_at) mais sont privés : ils ne sortent pas dans attributes — 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 description provient directement du document Meili tel quel.

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éfaut 20, plafond 100).
    • offset (int ≥ 0, défaut 0).
    • 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 sur call_price.price), udated_at / -udated_at, udated_at_asc. Valeur hors whitelist → 422 Invalid sort (évite d’exposer un attribut absent de sortableAttributes ce qui 503erait Meili).
    • establishment (optionnel) — [a-f0-9]{32} strict ; filtre establishment.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 de sort hors whitelist.
    • 422 Invalid establishment id?establishment mal formé (pas [a-f0-9]{32}).
    • 503 Search backend unavailable.

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).
    • limit plafonné à 50 (vs 100 sur /api/offers).
    • distance borné par OFFER_NEARBY_MAX_DISTANCE_METERS (défaut 50 km).
    • lat/lng/distance mutuellement requis ⇒ partial input = 422.
  • Pipeline : pass-through des hits Meili (formatter partagé), avec attributes.distanceMeters rempli quand le tri géo est actif.
  • Réponse 200 OK : ressources offers avec navigation links.{self,first,prev,next,last} et meta.{totalHits,limit,offset,query[,center,distance]}. Même shape attributes que /api/offers (formatter partagé).
  • Réponses d’erreur : identiques à /api/establishments/search (422 pour params invalides ou incomplets, 503 si Meilisearch est indisponible).

Pré-requis index Meilisearch (one-shot, sans ça le mode géo 503e) :

  • _geo dans filterableAttributes ET sortableAttributes
  • les champs texte recherchables dans searchableAttributes

Index versionné : MEILISEARCH_OFFERS_INDEX=offers_v2 en prod comme en dev (contrairement à media_dev/users_dev). C’est volontaire : les rolls de schéma d’offres se font en créant un offers_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.

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’attribut id est filterable) — pas de recherche full-text, le scorer pourrait surfacer un mauvais doc sur des préfixes d’id.

  • Réponse 200 OK : ressource unique offers avec la même shape attributes que 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.

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 Bearer rattache l’utilisateur au clic ; anonyme OK).

  • Action : CreateOfferClickAction.

  • Path :

    • {id} — offre, regex stricte [a-f0-9]{32}.
  • Body (JSON) : { "mediaId": "<32-hex>" }mediaId obligatoire (le contexte média est toujours requis).

  • Effet : mint un clickRef opaque (UUIDv7 hex), persiste une ligne tracking_click (média obligatoire + utilisateur nullable + visiteur), et émet le cookie visiteur si activé. Seul le clickRef opaque est renvoyé — jamais userId/PII.

  • Réponse 201 Created — ressource JSON:API clicks ; data.id EST le clickRef :

{
"data": {
"type": "clicks",
"id": "0193a1b2c3d4e5f60718293a4b5c6d7e",
"attributes": {
"clickRef": "0193a1b2c3d4e5f60718293a4b5c6d7e",
"subidParam": "subid",
"url": "https://merchant.example.com/deal/123?subid=0193a1b2c3d4e5f60718293a4b5c6d7e"
}
}
}
AttributSens
clickRefle subid opaque à insérer dans le lien affilié (= data.id)
subidParamnom de paramètre subid à utiliser (config TRACKING_SUBID_PARAM) ; utile si tu construis le lien côté client
urlURL 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 bodymediaId manquant/mal formé ou body non-JSON.
    • 503 Search backend unavailable.

Exemple

Terminal window
curl -X POST \
-H "Content-Type: application/json" \
-d '{"mediaId":"a1b2c3d4e5f600112233445566778899"}' \
"http://hydrogen.dev.com/api/offers/0f1e2d3c4b5a69788796a5b4c3d2e1f0/clicks"

Le clickRef remonte ensuite dans le postback de conversion (POST /admin/tracking/conversions, champ clickRef) et est résoluble via GET /admin/tracking/clicks/{ref} (voir docs/admin.md).


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 Bearer est 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 = 302 brut, erreurs = JSON:API.

  • Path :

    • {id} — regex stricte [a-f0-9]{32} (même shape canonique que GET /api/offers/{id}).
  • Résolution : filtre Meili exact id = "<hex>", on ne récupère que id + url.

  • Comptage : le clic part dans un tampon (tracking_event) vidé en fin de requête (register_shutdown_function), drainé par le worker bin/tracking-flush.php vers tracking_stats/tracking_daily. Les bots (crawlers, dépliage de liens, User-Agent vide) sont ignorés quand TRACKING_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 (voir docs/admin.md).

  • Réponse 302 Found : header Location = URL marchand de l’offre. Aucun corps.

  • Réponses d’erreur :

    • 404 Offer not found — id mal formé, offre absente, ou offre sans champ url exploitable.
    • 503 Search backend unavailable.

Exemple

Terminal window
curl -i "http://hydrogen.dev.com/go/offer/0f1e2d3c4b5a69788796a5b4c3d2e1f0"
# HTTP/1.1 302 Found
# Location: https://merchant.example.com/deal/123?aff=hexatrip

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 Bearer présent rattache l’utilisateur au clic). Les clics anonymes sont gérés nativement (userId nul).

  • Action : RedirectOfferMediaAction.

  • Hors groupe /api : succès = 302 brut, 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 clickRef opaque (UUIDv7 en 32 hex), on persiste une ligne tracking_click reliant clickRef → média (obligatoire) + utilisateur (nullable) + visiteur, puis on ajoute le clickRef à l’URL marchande comme paramètre subid (nom configurable via TRACKING_SUBID_PARAM). Le réseau renvoie ce subid dans son postback de conversion (POST /admin/tracking/conversions, champ clickRef) pour une attribution précise clic → média → utilisateur.

  • Confidentialité : seul le clickRef opaque sort du système — jamais userId ni PII. L’identité reste résoluble côté Admin via GET /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 : header Location = URL marchand + subid. Émet aussi un Set-Cookie visiteur si un nouvel id est minté.

  • Réponses d’erreur :

    • 404 Offer not found / 404 Media not found — id mal formé, offre absente/sans url.
    • 503 Search backend unavailable.
  • Robustesse : un échec de persistance du clic ne casse pas la redirection (fallback sur l’URL sans subid).

Exemple

Terminal window
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