---
title: "Points d'intérêt"
description: "Triplet /api/pois (list paginé + facettes) / /api/pois/search (full-text + géo + facettes) / /api/pois/{id} (détail) adossé à l'index Meilisearch MEILISEARCH_POIS_INDEX…"
---

Triplet `/api/pois` (list paginé + facettes) / `/api/pois/search` (full-text + géo + facettes) / `/api/pois/{id}` (détail) adossé à l'index Meilisearch `MEILISEARCH_POIS_INDEX` (défaut `pois`). Catalogue **~210 000 documents** dérivés d'OpenStreetMap ⇒ pagination obligatoire sur les listings.

Comme les établissements, Hydrogen **n'a pas de domaine `Poi` côté MySQL** : l'index Meilisearch est la **source de vérité**, alimenté par un ETL 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 `pois` est le **hash hex 32 caractères en minuscules** (UUID sans tirets). Les documents source stockent le `id` **en majuscules** (`AED604B47ADD11F196D500155DDA08DE`) ; on normalise vers le lowercase pour l'URL/JSON:API et l'endpoint de détail remet en majuscules avant d'interroger l'index — l'espace d'URL reste **insensible à la casse** de bout en bout (`GET /api/pois/aed6…` = `GET /api/pois/AED6…`).

**Shape attributes commune (formatter partagé)** : les 3 endpoints utilisent `PoiHitFormatter`, 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 depuis l'index : `osm_id`, `osm_type`, `name`, `alt_names[]`, `names{}`, `category`, `subcategories[]`, `address`, `postcode`, la hiérarchie `country_id`/`region_id`/`subregion_id`/`city_id` + leurs libellés dénormalisés `country`/`region`/`subregion`/`city`, `importance`, `has_geo`, `attributes{}` (tags OSM bruts), et les champs optionnels `opening_hours`, `phone`, `website`, `wikidata`, `wikipedia`. Leur format est défini par le pipeline d'alimentation, pas par Hydrogen.

> **Pas de description Markdown ni d'assets statiques ici** — contrairement aux pays/régions/sous-régions (blurb éditorial) ou aux établissements (carousel `images` + description), les POIs sont dérivés d'OpenStreetMap et ne portent aucun asset on-disk : le document Meili est la ressource entière.

## Facettes communes (list + search)

Les deux endpoints de listing partagent le même jeu de filtres, cumulables (composés en `AND` côté Meili) et validés à l'identique via `PoiFilters`. Chaque valeur est normalisée à la casse stockée dans l'index avant d'être injectée dans le filtre :

| Query          | Champ Meili     | Format attendu                     | Exemple        |
|----------------|-----------------|------------------------------------|----------------|
| `category`     | `category`      | slug minuscule (`[a-z0-9_-]{1,50}`) | `transport`    |
| `country`      | `country_id`    | ISO 3166-1 alpha-2                 | `fr`           |
| `region`       | `region_id`     | ISO 3166-2                         | `fr-ara`       |
| `subregion`    | `subregion_id`  | ISO 3166-2                         | `fr-38`        |
| `city`         | `city_id`       | hex 32                             | `676584c2…`    |
| `hasGeo`       | `has_geo`       | `true` / `false` (`1` / `0`)       | `true`         |

Toute valeur mal formée renvoie un `422` ciblé (`Invalid category` / `Invalid country code` / `Invalid region code` / `Invalid subregion code` / `Invalid city id` / `Invalid hasGeo`) avec le `pointer` correspondant. Les filtres appliqués sont ré-échoés (normalisés) dans `meta`.

## `GET /api/pois`

Listing paginé du catalogue, sans filtre full-text ni géo (pour ça, voir `/api/pois/search`), avec les facettes ci-dessus.

- **Auth** : aucune.
- **Action** : [ListPoisAction](../src/Http/Action/Api/Poi/ListPoisAction.php).
- **Query** :
  - `limit` (int, défaut `20`, plafond `100`).
  - `offset` (int ≥ 0, défaut `0`).
  - `sort` (string, optionnel) — whitelist : `importance` / `-importance` (= défaut, desc), `importance_asc`, `name` (asc), `-name` (desc). Valeur hors whitelist → `422 Invalid sort`.
  - facettes : `category`, `country`, `region`, `subregion`, `city`, `hasGeo` (cf. section précédente).
- **Tri par défaut** : `importance:desc` — les POIs les plus notables en haut.

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "pois",
      "id":   "aed604b47add11f196d500155dda08de",
      "attributes": {
        "osm_id":        136221,
        "osm_type":      "node",
        "name":          "Chavant",
        "alt_names":     [],
        "names":         { "default": "Chavant" },
        "category":      "transport",
        "subcategories": ["tramway_station"],
        "address":       null,
        "postcode":      null,
        "country_id":    "FR",
        "region_id":     "FR-ARA",
        "subregion_id":  "FR-38",
        "city_id":       "676584C252B711F196D500155DDA08DE",
        "country":       "France",
        "region":        "Auvergne-Rhône-Alpes",
        "subregion":     "Isère",
        "city":          "Grenoble",
        "importance":    0.0,
        "has_geo":       true,
        "attributes":    { "access": { "tram": "yes", "railway": "tram_stop" } },
        "latitude":      45.1845327,
        "longitude":     5.7317282
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/pois?category=transport&limit=20",
    "first": "https://api.example/api/pois?category=transport&limit=20",
    "prev":  null,
    "next":  "https://api.example/api/pois?category=transport&limit=20&offset=20",
    "last":  "https://api.example/api/pois?category=transport&limit=20&offset=980"
  },
  "meta": {
    "totalHits": 12043,
    "limit":     20,
    "offset":    0,
    "count":     20,
    "sort":      "importance:desc",
    "category":  "transport"
  }
}
```

- **Réponses d'erreur** :
  - `422 Invalid sort` — valeur de `sort` hors whitelist.
  - `422 Invalid …` — valeur de facette mal formée (cf. section Facettes).
  - `503 Search backend unavailable`.

## `GET /api/pois/search`

Recherche de POIs par **nom** (`?q=…`), par **proximité GPS** (`?lat=…&lng=…&distance=…`), par **facettes**, ou toute combinaison des trois.

- **Auth** : aucune (endpoint public).
- **Action** : [SearchPoisAction](../src/Http/Action/Api/Poi/SearchPoisAction.php).
- **Query** :
  - `q` (optionnel) — recherche full-text (searchable : `name`, `alt_names`, `names`, `city`, `category`, `subcategories`, `region`, `country`, `address`, `postcode`). Sans `q`, Meilisearch retourne tous les documents (filtrés par facette/géo si présent).
  - `lat`, `lng`, `distance` — **tous les trois ou aucun**. Fournir l'un sans les autres ⇒ `422 Incomplete geo parameters`. Avec eux, le filtre `_geoRadius(lat, lng, distance)` s'applique ET le tri bascule sur `_geoPoint(lat, lng):asc` (du plus proche au plus éloigné).
    - `lat` : float dans `[-90, 90]`.
    - `lng` : float dans `[-180, 180]`.
    - `distance` : entier positif (mètres), borné par `POI_NEARBY_MAX_DISTANCE_METERS` (défaut **50 km**). Au-delà → `422 Distance too large`.
  - facettes : `category`, `country`, `region`, `subregion`, `city`, `hasGeo` (cumulables avec `q` et/ou le géo).
  - `limit` (1..50, défaut 20).
  - `offset` (≥0, défaut 0).

- **Réponse `200 OK`** :

```json
{
  "data": [
    {
      "type": "pois",
      "id":   "aed604b47add11f196d500155dda08de",
      "attributes": {
        "name":          "Chavant",
        "category":      "transport",
        "subcategories": ["tramway_station"],
        "country_id":    "FR",
        "city":          "Grenoble",
        "importance":    0.0,
        "has_geo":       true,
        "latitude":      45.1845327,
        "longitude":     5.7317282,
        "distanceMeters": 214.8
      }
    }
  ],
  "links": {
    "self":  "https://api.example/api/pois/search?q=chavant&lat=45.18&lng=5.73&distance=2000&limit=20",
    "first": "https://api.example/api/pois/search?q=chavant&lat=45.18&lng=5.73&distance=2000&limit=20",
    "prev":  null,
    "next":  null,
    "last":  null
  },
  "meta": {
    "totalHits": 3,
    "limit":     20,
    "offset":    0,
    "query":     "chavant",
    "category":  "transport",
    "center":    { "lat": 45.18, "lng": 5.73 },
    "distance":  2000
  }
}
```

  - `meta.totalHits` : **estimé** par Meilisearch (sémantique offset).
  - `meta.center` et `meta.distance` ne sont présents que si le mode géo est actif.
  - `attributes.distanceMeters` n'est présent que sur les hits issus d'un tri `_geoPoint:asc` (mode géo).
  - Les clés de facettes (`category`, `country`, …) ne sont présentes dans `meta` que si le filtre correspondant a été fourni.

- **Pagination** : offset-based, navigation via `links.{self,first,prev,next,last}`.

- **Réponses d'erreur** :
  - `422 Incomplete geo parameters` — un seul de `lat`/`lng`/`distance` est fourni (ou deux sur trois).
  - `422 Invalid latitude` / `Invalid longitude` / `Invalid distance` — valeurs hors plage ou non numériques.
  - `422 Distance too large` — `distance` > `POI_NEARBY_MAX_DISTANCE_METERS`.
  - `422 Invalid …` — valeur de facette mal formée.
  - `503 Search backend unavailable` — Meilisearch injoignable, index manquant, ou pré-requis index non satisfaits. Le détail Meili est propagé dans `errors[0].detail`.

> **Pré-requis index Meilisearch** (ops one-shot) :
> - `_geo` dans `filterableAttributes` ET `sortableAttributes` (mode géo)
> - `category`, `subcategories`, `country_id`, `region_id`, `subregion_id`, `city_id`, `has_geo` dans `filterableAttributes` (facettes)
> - `importance`, `name` dans `sortableAttributes` (tri du listing)
> - champs textuels (`name`, `alt_names`, …) dans `searchableAttributes`

## `GET /api/pois/{id}`

Détail d'un POI par son id hex.

- **Auth** : aucune.
- **Action** : [GetPoiAction](../src/Http/Action/Api/Poi/GetPoiAction.php).
- **Path** :
  - `{id}` — regex `[a-fA-F0-9]{32}` (**insensible à la casse**). L'action met en majuscules pour interroger l'index (ids stockés upper) et renvoie le JSON:API id en minuscules.
- **Résolution** : filtre Meili exact `id = "<HEX_UPPER>"` sur l'index (l'attribut `id` est filterable) — pas de recherche full-text.

- **Réponse `200 OK`** :

```json
{
  "data": {
    "type": "pois",
    "id":   "aed604b47add11f196d500155dda08de",
    "attributes": {
      "osm_id":        136221,
      "osm_type":      "node",
      "name":          "Chavant",
      "category":      "transport",
      "subcategories": ["tramway_station"],
      "country_id":    "FR",
      "region_id":     "FR-ARA",
      "subregion_id":  "FR-38",
      "city_id":       "676584C252B711F196D500155DDA08DE",
      "country":       "France",
      "region":        "Auvergne-Rhône-Alpes",
      "subregion":     "Isère",
      "city":          "Grenoble",
      "importance":    0.0,
      "has_geo":       true,
      "attributes":    { "access": { "tram": "yes", "railway": "tram_stop" } },
      "latitude":      45.1845327,
      "longitude":     5.7317282
    }
  }
}
```

- **Réponses d'erreur** :
  - `404 POI 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`.

---
