# API publique Okkre

Cette API permet a un site de club ou a un service tiers de lire les terrains,
afficher les disponibilites et creer ou annuler des reservations dans Okkre.

Base URL :

```text
/api/v1/clubs/{clubId}
```

Toutes les requetes doivent envoyer une cle API dans le header
`Authorization`.

```http
Authorization: Bearer <cle_api>
```

Les deux informations sont necessaires : `{clubId}` dans l'URL identifie le
club cible et la cle API autorise l'appel. Le serveur verifie que la cle
appartient bien a ce club ; une cle seule ne suffit donc pas pour construire
l'URL.

## Prerequis

- Le club doit etre configure dans Okkre.
- Une cle API doit etre creee dans les reglages API du club.
- Le serveur doit avoir `SUPABASE_SERVICE_ROLE_KEY`.
- La base doit avoir les migrations appliquees, notamment
  `20260829120000_public_api_service_role_grants.sql`.
- Pour les appels navigateur depuis un autre domaine, configurer
  `PUBLIC_API_ALLOWED_ORIGINS` cote serveur, par exemple :

```text
PUBLIC_API_ALLOWED_ORIGINS=https://club.example,https://staging.club.example
```

Les appels serveur a serveur ne sont pas concernes par CORS.

## Droits des cles

Deux niveaux de cle existent :

| Droit | Capacites |
| --- | --- |
| `read` | Lire les terrains, disponibilites et reservations creees par l'API. |
| `book` | Tout `read`, plus creer et annuler des reservations. |

Une cle revoquee renvoie `401 api_key_revoked`. Une cle `read` utilisee sur un
endpoint de reservation renvoie `403 insufficient_scope`.

La gestion des cles est reservee au proprietaire du club. Les contacts `staff`
du club n'ont pas acces aux reglages ni aux cles API.

## Endpoints

| Methode | Endpoint | Droit | Description |
| --- | --- | --- | --- |
| `GET` | `/courts` | `read` | Liste les terrains actifs du club. |
| `GET` | `/availability?from=YYYY-MM-DD&to=YYYY-MM-DD&sport=padel&court_id=...` | `read` | Liste les creneaux disponibles avec leur duree et leur prix configures. |
| `POST` | `/reservations` | `book` | Cree une reservation idempotente. |
| `GET` | `/reservations/{externalId}` | `read` | Lit une reservation creee par l'API. |
| `DELETE` | `/reservations/{externalId}` | `book` | Annule une reservation creee par l'API. |
| `POST` | `/reservations/{externalId}/cancel` | `book` | Variante explicite pour annuler une reservation. |

## Format des erreurs

Toutes les erreurs utilisent ce format :

```json
{
  "error": {
    "code": "validation_error",
    "message": "date doit etre une date valide au format YYYY-MM-DD."
  }
}
```

En developpement local, certaines erreurs Supabase ajoutent `error.details`
avec l'operation et le code base de donnees. Ces details ne sont pas renvoyes
en production.

Codes courants :

| HTTP | Code | Cas |
| --- | --- | --- |
| `400` | `validation_error` | Corps JSON invalide, champ manquant, date ou horaire invalide. |
| `400` | `invalid_period` | Parametres `from` / `to` invalides ou periode superieure a 31 jours. |
| `401` | `missing_api_key` | Header `Authorization` absent. |
| `401` | `invalid_authorization_header` | Header different de `Bearer <cle_api>`. |
| `401` | `api_key_not_found` | Cle inconnue dans la base utilisee par le serveur. |
| `401` | `api_key_revoked` | Cle revoquee. |
| `403` | `api_key_wrong_club` | Cle valide mais appartenant a un autre club. |
| `403` | `insufficient_scope` | Cle `read` utilisee sur une action `book`. |
| `404` | `club_not_found` | Club introuvable pour les disponibilites. |
| `404` | `reservation_not_found` | Reservation API introuvable. |
| `409` | `slot_unavailable` | Terrain deja occupe sur ce creneau. |
| `500` | `database_error` | Operation Supabase impossible. |
| `500` | `internal_error` | Erreur serveur inattendue. |
| `503` | `api_key_lookup_failed` | Verification de cle impossible. |

## GET /courts

Retourne les terrains actifs, tries par `display_order`.

```bash
curl "$BASE_URL/courts" \
  -H "Authorization: Bearer $OKKRE_API_KEY"
```

Reponse :

```json
{
  "courts": [
    {
      "id": "8b5d5b7d-8b91-4b22-a6d8-3a6d5f5c2a10",
      "name": "Terrain 1",
      "sport": "padel",
      "environment": "indoor",
      "display_order": 1
    }
  ]
}
```

## GET /availability

Retourne les creneaux disponibles selon la duree configuree dans la regle
d'ouverture (par exemple `120` minutes), avec le prix applicable a chaque
creneau. Les disponibilites tiennent compte des regles d'ouverture publiees et
des occupations confirmees.

Parametres :

| Parametre | Obligatoire | Description |
| --- | --- | --- |
| `from` | Oui | Date de debut au format `YYYY-MM-DD`. |
| `to` | Non | Date de fin au format `YYYY-MM-DD`. Si absent, vaut `from`. |
| `sport` | Non | Filtre par sport, par exemple `padel`. |
| `court_id` | Non | Filtre par identifiant de terrain. |

La periode ne peut pas depasser 31 jours.

```bash
curl "$BASE_URL/availability?from=2026-09-12&to=2026-09-13&sport=padel" \
  -H "Authorization: Bearer $OKKRE_API_KEY"
```

Reponse :

```json
{
  "availability": [
    {
      "court": "Terrain 1",
      "date": "2026-09-12",
      "start_time": "18:00",
      "end_time": "20:00",
      "price_cents": 2400
    }
  ]
}
```

Le champ `court` est le nom lisible du terrain. Il peut etre reutilise tel quel
dans `POST /reservations`. `price_cents` est le prix du creneau entier, en
centimes d'euro : reutiliser exactement les heures renvoyees garantit le tarif
affiche.

## POST /reservations

Cree une reservation. Cet endpoint exige une cle `book`.

```bash
curl -X POST "$BASE_URL/reservations" \
  -H "Authorization: Bearer $OKKRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "external_id": "site-order-123",
    "court": "Terrain 1",
    "date": "2026-09-12",
    "start_time": "18:00",
    "end_time": "19:30",
    "person": {
      "first_name": "Ada",
      "last_name": "Lovelace",
      "email": "ada@example.com"
    },
    "notes": "Optionnel"
  }'
```

Corps JSON :

| Champ | Obligatoire | Description |
| --- | --- | --- |
| `external_id` | Oui | Identifiant unique cote site ou integrateur. Sert a l'idempotence. |
| `court` | Oui | Nom lisible du terrain, ou UUID du terrain. |
| `date` | Oui | Date locale du club, au format `YYYY-MM-DD`. |
| `start_time` | Oui | Heure locale du club au format `HH:MM`. Doit commencer a l'heure ou a la demi-heure. |
| `end_time` | Oui | Heure locale du club au format `HH:MM`. La duree doit etre un multiple de 30 minutes. |
| `person.first_name` | Oui | Prenom, 80 caracteres maximum. |
| `person.last_name` | Oui | Nom, 80 caracteres maximum. |
| `person.email` | Oui si pas de telephone | Email du client. Sert a retrouver une fiche existante. |
| `person.phone` | Oui si pas d'email | Telephone du client, entre 6 et 30 caracteres. |
| `notes` | Non | Notes libres, tronquees a 500 caracteres. |

Reponse `201` :

```json
{
  "reservation": {
    "id": "32ec69b4-7fd9-4508-9f9d-022bb2dc0f8c",
    "external_id": "site-order-123",
    "court_id": "8b5d5b7d-8b91-4b22-a6d8-3a6d5f5c2a10",
    "starts_at": "2026-09-12T16:00:00.000Z",
    "ends_at": "2026-09-12T17:30:00.000Z",
    "price_cents": 2400,
    "status": "confirmed",
    "cancellation_reason": null
  },
  "idempotent_replay": false
}
```

`starts_at` et `ends_at` sont renvoyes en ISO UTC. Les champs `date`,
`start_time` et `end_time` envoyes par le client sont interpretes dans le
fuseau horaire du club.

### Idempotence

`external_id` doit etre stable pour une tentative de reservation donnee.
Si le meme `external_id` existe deja pour le club, l'API ne cree pas de doublon
et renvoie la reservation existante :

```json
{
  "reservation": {
    "id": "32ec69b4-7fd9-4508-9f9d-022bb2dc0f8c",
    "external_id": "site-order-123",
    "court_id": "8b5d5b7d-8b91-4b22-a6d8-3a6d5f5c2a10",
    "starts_at": "2026-09-12T16:00:00.000Z",
    "ends_at": "2026-09-12T17:30:00.000Z",
    "status": "confirmed",
    "cancellation_reason": null
  },
  "idempotent_replay": true
}
```

La fiche client est recherchee d'abord par email, puis par telephone. Si elle
n'existe pas, elle est creee avec le role `client`. Les demandes simultanees
avec le meme nouvel email reutilisent la fiche creee par la premiere demande.

## GET /reservations/{externalId}

Lit une reservation creee par l'API pour ce club.

```bash
curl "$BASE_URL/reservations/site-order-123" \
  -H "Authorization: Bearer $OKKRE_API_KEY"
```

Reponse :

```json
{
  "reservation": {
    "id": "32ec69b4-7fd9-4508-9f9d-022bb2dc0f8c",
    "external_id": "site-order-123",
    "court_id": "8b5d5b7d-8b91-4b22-a6d8-3a6d5f5c2a10",
    "starts_at": "2026-09-12T16:00:00.000Z",
    "ends_at": "2026-09-12T17:30:00.000Z",
    "status": "confirmed",
    "cancellation_reason": null
  }
}
```

## DELETE /reservations/{externalId}

Annule une reservation creee par l'API. La reservation reste dans l'historique
avec le statut `cancelled`.

```bash
curl -X DELETE "$BASE_URL/reservations/site-order-123" \
  -H "Authorization: Bearer $OKKRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Annulation client" }'
```

Le corps JSON est optionnel. `reason` est tronque a 300 caracteres.

Reponse :

```json
{
  "reservation": {
    "id": "32ec69b4-7fd9-4508-9f9d-022bb2dc0f8c",
    "external_id": "site-order-123",
    "court_id": "8b5d5b7d-8b91-4b22-a6d8-3a6d5f5c2a10",
    "starts_at": "2026-09-12T16:00:00.000Z",
    "ends_at": "2026-09-12T17:30:00.000Z",
    "status": "cancelled",
    "cancellation_reason": "Annulation client"
  },
  "idempotent_replay": false
}
```

Rejouer l'annulation d'une reservation deja annulee est idempotent et renvoie
`idempotent_replay: true`.

## POST /reservations/{externalId}/cancel

Fait la meme annulation logique que `DELETE /reservations/{externalId}`.
Ce format est utile pour les clients qui ne veulent pas envoyer de corps avec
une requete `DELETE`.

```bash
curl -X POST "$BASE_URL/reservations/site-order-123/cancel" \
  -H "Authorization: Bearer $OKKRE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Annulation client" }'
```

## Conseils d'integration

- Stocker la cle API cote serveur, jamais dans du JavaScript public.
- Utiliser une cle `read` pour afficher les disponibilites publiques.
- Utiliser une cle `book` uniquement sur un backend controle par le club.
- Generer un `external_id` unique et stable, par exemple l'identifiant de
  commande du site.
- Verifier la disponibilite juste avant de creer une reservation.
- Traiter `409 slot_unavailable` comme un conflit normal : demander au client de
  choisir un autre creneau.
- Ne pas parser `message` pour la logique applicative ; utiliser `error.code`.

## Tests locaux

Avec la base Supabase locale demarree :

```bash
pnpm test:api
```

Le test lance un serveur Next sur le port `3107`, cree ses donnees de test,
puis les supprime. Il verifie les cles, les terrains actifs, la creation, la
lecture, l'annulation, les conflits et les restrictions d'administration.
