# Mission : implémenter le Kiosk Integration Contract ApiBorne

> Document d'implémentation autonome destiné à un assistant IA (Claude Code).
> Déposez ce fichier à la racine du projet de l'éditeur et demandez :
> « Lis APIBORNE_IMPLEMENTATION.md et implémente le contrat dans ce projet. »
>
> Références : site officiel https://www.apiborne.com/ · documentation
> développeur https://developers.apiborne.com/ · implémentation de référence
> https://github.com/ApiBorne/ApiborneDemoImpl · specs OpenAPI téléchargeables
> sur https://developers.apiborne.com/openapi/ (openapi.yaml, openapi-config.yaml,
> openapi-server.yaml).

## 0. Contexte et mission

**ApiBorne** fournit des bornes d'accueil patient (self check-in) pour
cliniques, hôpitaux et centres d'imagerie : identification par carte de santé
ou QR code, vérification des données administratives, scan des documents
requis, ticket d'appel, cockpit d'accueil.

**Ta mission** : implémenter, dans le système de gestion médical de ce projet
(le « système éditeur » : celui qui possède les patients et les rendez-vous),
le **Kiosk Integration Contract** — l'API REST que la borne appelle en direct
pendant le parcours patient. Le contrat est vendor-neutral : JSON camelCase,
dates ISO 8601, identifiants opaques choisis par l'éditeur.

Architecture (trois canaux) :

1. **Routes de communication** (12, borne → éditeur) : le parcours patient.
2. **Routes de configuration** (6, serveur ApiBorne → éditeur) : les
   référentiels (lieux, types d'examen, praticiens, salles, examens, types de
   documents) qui alimentent l'admin ApiBorne.
3. **Services offerts par le serveur** (éditeur → serveur ApiBorne,
   optionnels) : numérotation de tickets partagée, push de statuts, etc.

Règle d'or : pendant un parcours patient, l'éditeur ne rappelle **JAMAIS** le
serveur ApiBorne. La borne pousse tout le contexte nécessaire dans ses
requêtes (`context.config`).

## 1. Questions à poser au développeur AVANT de coder

1. Quel framework/langage sert les APIs HTTP de ce projet, et où vivent les
   entités patient / rendez-vous / document ?
2. Sur quelle base URL le contrat sera-t-il exposé ? (le chemin sous cette
   base est IMPOSÉ : `/api/apiborneIntegrationService/v1`)
3. Comment provisionner la clé partagée (`X-Kiosk-Auth-Key`) et les devices
   borne ? (table dédiée, config, trust-on-first-use en dev…)
4. L'établissement est-il multi-sites ? (header `X-Kiosk-Office-Id` à honorer)
5. Y a-t-il une gestion documentaire, une file d'appel, des comptes
   personnel ? Pour chaque « non » : implémenter la forme minimale conforme
   (voir §4) — jamais omettre la route.

## 2. Socle transversal (à implémenter en premier)

### 2.1 Base path et formats

- Toutes les routes kiosque et configuration vivent sous
  `{base}/api/apiborneIntegrationService/v1`.
- JSON camelCase ; `birthDate` = `YYYY-MM-DD` ; `startDate` avec offset de
  fuseau (`2026-07-21T10:30:00+02:00`).
- Identifiants (`patient.id`, `appointment.id`, `locationId`, `examId`,
  `examTypeId`, `practitionerId`, `roomId`, `documentType`) : chaînes opaques
  choisies par l'éditeur, STABLES pendant tout un parcours. Un
  `appointment.id` ne doit jamais valoir littéralement `by-code` (collision
  de route).

### 2.2 Authentification (routes de communication)

Deux headers sur chaque requête borne, à valider DANS CET ORDRE :

```
X-Kiosk-Auth-Key: <clé d'autorisation partagée>
X-Kiosk-Device-Id: <identifiant du device borne>
```

1. Device inconnu → `401 { "error": { "code": "UNKNOWN_DEVICE" } }`
2. Clé invalide pour ce device → `401 { "error": { "code": "INVALID_AUTH_KEY" } }`

- Un 401 ne doit JAMAIS devenir une redirection (302 vers une page de login).
- Exceptions « serveur → serveur » (clé seule, pas de device id) :
  `PUT /appointments/{id}/status`, `POST /staff/sign-in`, et les 6 routes de
  configuration.
- ApiBorne envoie aussi `X-Kiosk-Office-Id` (identité de l'établissement,
  format libre convenu à l'installation) : un éditeur multi-sites DOIT s'en
  servir pour choisir l'établissement cible ; un mono-site l'ignore.

### 2.3 CORS (obligatoire)

La borne est une application navigateur. Répondre aux preflights `OPTIONS` :

```
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, X-Kiosk-Auth-Key, X-Kiosk-Device-Id, X-Kiosk-Office-Id
```

### 2.4 Erreurs normalisées

Statuts HTTP réels (jamais « 200 + flag d'erreur ») et corps :

```json
{ "error": { "code": "UNKNOWN_APPOINTMENT", "message": "pour les logs", "details": null } }
```

Codes : `INVALID_AUTH_KEY` (401), `UNKNOWN_DEVICE` (401),
`INVALID_CREDENTIALS` (401), `VALIDATION_ERROR` (400), `UNKNOWN_PATIENT` /
`UNKNOWN_APPOINTMENT` / `UNKNOWN_DOCUMENT` (404), `ALREADY_CHECKED_IN` (409),
`UPLOAD_TOO_LARGE` (413), `INTERNAL_ERROR` (500). `NOT_SUPPORTED` (501) est
DÉPRÉCIÉ — toutes les routes étant obligatoires, répondre la forme minimale.

Politique de retry de la borne : erreurs réseau et 5xx retentées, **4xx
jamais**. Les écritures doivent donc tolérer un rejeu après timeout
(idempotence, voir check-in).

### 2.5 `context` (sur chaque POST/PATCH/PUT de la borne)

```json
{
  "context": {
    "locationId": "loc-42",
    "config": { "identification": { "twoFieldsIdentification": true }, "documents": {}, "checkIn": {} },
    "vendorData": { "votreCleInterne": 123 }
  }
}
```

- `context.config` fourni → l'UTILISER, et ne jamais rappeler ApiBorne pour
  de la configuration. Absent → votre propre configuration. Propriétés
  inconnues → ignorées sans erreur.
- `context.vendorData` : round-trip de VOS objets opaques. Vous pouvez poser
  un `vendorData` (< 4 Ko) sur la réponse, sur un `patient` ou un
  `appointment` ; la borne le mémorise par id d'entité et le renvoie sur les
  appels visant cette entité. Optimisation, pas un canal d'état obligatoire :
  vos routes doivent fonctionner sans. Pas de secret dedans (transite par le
  navigateur).

## 3. Les 12 routes de communication (borne → éditeur)

TOUTES OBLIGATOIRES. Ordre d'implémentation conseillé : les 5 routes du
parcours minimal (†) d'abord — une borne fait un check-in complet avec elles
seules — puis les autres.

### 3.1 † `POST /patients/identify` — operationId `identifyPatients`

Recherche des patients + leurs RDV DU JOUR. Corps :
`{ context, criteria: { socialSecurityId?, lastName?, firstName?, birthDate? } }`.

Règles :
- combiner TOUS les critères fournis ; le NIR (socialSecurityId) prime quand
  présent et doit TOLÉRER LES ESPACES ; attention aux cartes famille : le NIR
  seul est partagé par les bénéficiaires, les critères d'identité discriminent ;
- `config.identification.twoFieldsIdentification: true` → si la recherche
  stricte ne donne rien, tenter les croisements à deux champs (nom+date,
  prénom+date, nom+prénom) ;
- 0 résultat n'est PAS une erreur : `200 { "patients": [] }` ;
- patient sans RDV du jour → renvoyé avec `appointments: []` ;
- ne JAMAIS renvoyer les RDV d'un homonyme douteux (dans le doute : liste
  vide) ; cap ~10 patients ; exclure les RDV `cancelled`.

Réponse `200` :

```json
{
  "patients": [
    {
      "patient": {
        "id": "pat-77", "firstName": "Marie", "lastName": "Durand",
        "birthDate": "1980-05-12", "sex": "female",
        "email": null, "mobilePhone": "+33612345678",
        "address": { "line1": "12 rue des Lilas", "line2": null, "zipCode": "75011", "city": "Paris" },
        "socialSecurityId": "280057510612345", "heightCm": 168, "weightKg": 62,
        "referringPractitioner": { "name": "MARTIN Paul", "rppsId": "10101010101" },
        "vendorData": { "patientKey": 77 }
      },
      "appointments": [
        {
          "id": "apt-1001", "patientId": "pat-77",
          "startDate": "2026-07-21T10:30:00+02:00", "convocationDate": "2026-07-21T10:15:00+02:00",
          "locationId": "loc-42", "examId": "exam-77", "examTypeId": "examtype-9",
          "examLabel": "IRM lombaire", "status": "scheduled",
          "practitionerId": "prac-4", "roomId": "room-3",
          "prescriber": null, "ticketNumber": null, "ticketNumberFormatted": null,
          "managementPageUrl": "https://editeur.example.com/p/apt-1001",
          "vendorData": { "appointmentKey": 1001 }
        }
      ]
    }
  ]
}
```

Exposer TOUS les champs éditables du patient (téléphone, adresse complète,
mensurations, médecin traitant) : la borne pré-remplit son formulaire avec —
un champ manquant serait effacé à la sauvegarde.

### 3.2 † `GET /appointments/by-code/{code}` — `getAppointmentByCode`

Le patient scanne le QR de sa convocation ; le format du code est LIBRE (vous
le générez). Introuvable → `404 UNKNOWN_APPOINTMENT`. Pas de codes chez
vous ? 404 systématique, la route doit exister.

Réponse `200` : `{ "patient": {...}, "appointment": {...}, "otherAppointments": [...] }`
(les autres RDV du jour du même patient — alimente le parcours multi-RDV).

### 3.3 † `GET /appointments/{appointmentId}` — `getAppointmentById`

Même réponse que by-code. Rechargement canonique (après un PATCH patient qui
répond 204, au retour d'écran). AUSSI appelée par le serveur ApiBorne pour
re-vérifier les tickets du Cockpit : `status` et `startDate` exacts.

### 3.4 `PATCH /patients/{patientId}` — `updatePatient`

Corps `{ context, patient: {...} }`. Sémantique PATCH stricte : champ absent
= inchangé, `null` explicite = effacement. `address` et
`referringPractitioner` sont des objets imbriqués. NIR stocké sans espaces.
Réponse `204` sans corps. Minimal : appliquer ce que vous supportez, `204`.

### 3.5 `PUT /appointments/{appointmentId}/prescriber` — `setAppointmentPrescriber`

Corps `{ context, prescriber: { name, rppsId? } }` → `204`. En pratique
dormante (l'analyse d'ordonnance n'est pas activée en saveur libre).
Minimal : `204` sans effet.

### 3.6 `GET /appointments/{appointmentId}/documents` — `listAppointmentDocuments`

Réponse `200` :

```json
{
  "documents": [
    { "id": "doc-15", "documentType": "prescription", "label": "Ordonnance", "availableOnPhone": false }
  ],
  "requiredDocumentTypes": [
    { "documentType": "prescription", "label": "Ordonnance" },
    { "documentType": "mutualInsuranceCard", "label": "Carte Mutuelle" }
  ]
}
```

- `requiredDocumentTypes` : tableau d'OBJETS (jamais de chaînes nues) — la
  borne calcule les manquants par différence sur `documentType` ;
- minimal (pas de gestion documentaire) :
  `{ "documents": [], "requiredDocumentTypes": [] }` → la borne saute le flow.

### 3.7 `POST /appointments/{appointmentId}/documents` — `uploadAppointmentDocument`

Corps : `{ context, documentType, rotationAngle, pages: [{ contentBase64, mimeType }] }`.
- pages JPEG/PNG en base64 SANS préfixe `data:` ; accepter ≥ 10 Mo de corps
  (`413 UPLOAD_TOO_LARGE` au-delà de votre limite) ; répondre < 60 s ;
- réponse `201 { "documentId": "doc-16" }` (+ `analysis` optionnel — à
  omettre : l'IA a vocation à être portée par ApiBorne) ;
- remplacement = la borne fait DELETE de l'ancien puis POST du nouveau.

### 3.8 `DELETE /appointments/{appointmentId}/documents/{documentId}` — `deleteAppointmentDocument`

`204` ; inconnu/déjà supprimé → `404 UNKNOWN_DOCUMENT`.

### 3.9 † `GET /appointments/{appointmentId}/notification-readiness` — `getNotificationReadiness`

« Le dossier est-il complet au sens de l'éditeur ? » Query params fournis :
`identifiedWithHealthCard`, `sequenceNumber`, `examId`, `otherExamIds`.
Réponse `200 { "ready": true }` ou
`{ "ready": false, "reason": "notificationDisabled" | "notificationNotConfigured" }`.
DOIT TOUJOURS répondre — au pire `{ "ready": true }` ; une réponse négative
ajoute l'anomalie « dossier incomplet » au check-in sans bloquer le patient.

### 3.10 † `POST /appointments/{appointmentId}/check-in` — `checkInAppointment`

L'opération critique. Corps :

```json
{
  "context": { ... },
  "identifiedWithHealthCard": true,
  "attendantCheckIn": false,
  "anomalyCodes": ["PR"],
  "documentsComplete": true,
  "sequence": { "number": 1, "count": 2 },
  "mainAppointmentId": null,
  "examId": "exam-77",
  "linkedAppointmentIds": [],
  "otherExamIds": [],
  "proposedTicket": { "number": 12, "formattedNumber": "SC-12" }
}
```

Règles NON NÉGOCIABLES :
- **Idempotence** : la borne rejoue l'appel après timeout réseau. RDV déjà
  check-in → `200` avec le ticket EXISTANT, jamais un doublon. Réserver
  `409 ALREADY_CHECKED_IN` aux états réellement incompatibles (annulé, terminé).
- **`proposedTicket`** : le numéro déjà réservé (et imprimé) par la borne
  auprès du serveur ApiBorne. L'ADOPTER comme ticket d'appel (recommandé) et
  en tout cas l'accepter sans erreur — ticket imprimé, agenda et Cockpit
  doivent montrer le même numéro.
- **Multi-RDV** : un appel PAR RDV ; `sequence.number` 1 = principal, les
  suivants portent `mainAppointmentId`.
- `anomalyCodes` : codes métier vendor-neutral — stocker TELS QUELS.
- `documentsComplete` (booléen nullable) : à tracer si utile.
- Réponse `200 { "ticketNumber": 12, "ticketNumberFormatted": "SC-12" }` —
  puis exposer ce ticket dans `ticketNumber`/`ticketNumberFormatted` du RDV
  (la borne dira « enregistrement déjà effectué »). Pas de file d'appel :
  `200 {}`.

### 3.11 `PUT /appointments/{appointmentId}/status` — `setAppointmentStatus`

Appelée par le SERVEUR ApiBorne (clé seule) quand le Cockpit agit. Corps
`{ "status": "checkedIn" | "inCare" | "done" }`. Progression AVANT uniquement ;
déjà à l'état demandé ou au-delà → `200` idempotent ; annulé/terminé →
`400 VALIDATION_ERROR`. Réponse `200 { "status": "inCare" }`.

### 3.12 `POST /staff/sign-in` — `staffSignIn`

Login du personnel au Cockpit ApiBorne avec VOS identifiants (clé seule).
Corps `{ login, password }`. Réponse `200` :
`{ "offices": [{ "officeId": "42", "officeVisibleId": "CLN", "name": "Clinique du Parc" }], "userEmail": "...", "userDisplayName": "..." }`.
Identifiants invalides → `401 INVALID_CREDENTIALS`. Minimal (pas de comptes) :
401 systématique.

## 4. Formes minimales conformes (récapitulatif)

| Fonctionnalité absente | Implémentation minimale |
|---|---|
| Gestion documentaire | `GET documents` → listes vides ; upload/delete existent (jamais sollicités) |
| Édition patient / prescripteur | `204` sans effet |
| Codes de convocation | `404 UNKNOWN_APPOINTMENT` systématique |
| Comptes personnel | `401 INVALID_CREDENTIALS` systématique |
| Notion de notification | toujours `{ "ready": true }` |
| File d'appel | check-in `200 {}` (adopter quand même `proposedTicket` si présent) |
| Analyse d'ordonnance | omettre `analysis` dans la réponse d'upload |

## 5. Les 6 routes de configuration (serveur ApiBorne → éditeur)

GET sous la même base, auth `X-Kiosk-Auth-Key` SEULE (+ `X-Kiosk-Office-Id`).
Les 5 premières sont SONDÉES par ApiBorne pour déverrouiller son admin —
les implémenter tôt. Liste vide acceptée. Les ids doivent être EXACTEMENT
ceux portés par vos RDV.

| Route | Réponse |
|---|---|
| `GET /config/office-places` | `{ "officePlaces": [{ "id", "name" }] }` — id = `locationId` des RDV |
| `GET /config/exam-types` | `{ "examTypes": [{ "id", "name", "ticketPrefix"? }] }` — id = `examTypeId` |
| `GET /config/practitioners` | `{ "practitioners": [{ "id", "name", "rppsId"? }] }` — id = `practitionerId` |
| `GET /config/rooms` | `{ "rooms": [{ "id", "name" }] }` — id = `roomId` |
| `GET /config/exams` | `{ "exams": [{ "id", "name", "examTypeId" }] }` — id = `examId` |
| `GET /config/document-types` | `{ "documentTypes": [{ "documentType", "label" }] }` — recommandée, HORS sonde |

`/config/document-types` construit la liste de la page « Paramètres des
documents requis » de l'admin ApiBorne (libellé personnalisé et « sur
téléphone » s'y définissent). Reprendre le vocabulaire standard (§6) quand un
code correspond ; codes maison libres (`^[A-Za-z][A-Za-z0-9_-]*$`, ≤ 64).

## 6. Vocabulaire standard `documentType` (41 codes)

`prescription` (Ordonnance) · `bloodTest` (Analyse sanguine) · `careSheet`
(Feuille de soin) · `medicalReport` (Compte rendu) · `questionnaire` ·
`convocation` · `other` (défaut / inconnus) · `mutualInsuranceCard` (Carte
Mutuelle) · `workAccidentCertificate` · `occupationalDiseaseCertificate` ·
`cssRightsCertificate` · `urineTest` · `signedConsent` · `cardiologistLetter`
· `bhcgResults` · `coagulationResults` · `creatinineResults` ·
`calciumResults` · `psaResults` · `t21ScreeningResults` · `implantCard` ·
`hospitalizationForm` · `careAuthorization` · `implantCompatibilityForm` ·
`implantSurgeryReport` · `mriQuestionnaire` · `kneeQuestionnaire` ·
`endometriosisQuestionnaire` · `ctScanQuestionnaire` · `spineQuestionnaire` ·
`shoulderQuestionnaire` · `boneDensitometryQuestionnaire` ·
`pediatricExamFollowUpForm` · `quote` (Devis) · `staffReviewForm` ·
`pathologyResults` · `mammographyQuestionnaire` · `skullQuestionnaire` ·
`pelvicUltrasoundReport` · `lumbarMriReport` · `cervicalMriReport`.

Un code inconnu reçu doit être traité comme `other`.

## 7. Services offerts par le serveur ApiBorne (éditeur → ApiBorne, optionnels)

À implémenter en dernier, en **best-effort** (timeout court, jamais bloquer
le flux métier). Auth : header `Authorization` = la clé d'autorisation compte
BRUTE (la même valeur que `X-Kiosk-Auth-Key`, sans préfixe Bearer) ; le corps
porte `licenceUuid` (identifiant de licence, copié depuis l'admin ApiBorne,
page Connectivité).

| Service | Quand | Corps (essentiel) |
|---|---|---|
| `POST /api/kioskTicket/issueForAppointment` | arrivée pointée au guichet : obtenir un ticket dans la MÊME séquence que les bornes | `{ licenceUuid, officePlaceId, requestUid (nouveau par génération), examTypeId?, prefix?, contractAppointmentId, patientDisplayName, examLabel }` → `{ number, formattedNumber }` — repli numérotation locale si échec |
| `POST /api/kioskTicket/appointmentStatusChanged` | CHAQUE changement d'état d'un RDV du jour | `{ licenceUuid, contractAppointmentId, status }` (statuts contrat, retours arrière inclus) |
| `POST /api/kioskTicket/cancelForAppointment` | annulation de l'accueil (refaire l'accueil) | `{ licenceUuid, contractAppointmentId }` — les tickets passent `cancelled`, penser à effacer votre copie du ticket |
| `POST /api/kioskTicket/getCheckinNotifySettings` | avant d'évaluer vos conditions d'accueil (mode conf ApiBorne) | `{ licenceUuid }` → réglages globaux/par lieu + anomalies exclues PAR CODE |
| `POST /api/kioskTicket/printTicket` | réimpression d'un ticket depuis un poste d'accueil | `{ licenceUuid, officePlaceId, kioskDeskId?, data: {...} }` — ApiBorne rend le template et pilote l'imprimante |

Schémas complets : `openapi-server.yaml`.

## 8. Plan d'implémentation conseillé (avec cases à cocher)

- [ ] **Phase 0 — socle** : middleware auth (2 headers, ordre, 401 typés),
      CORS, corps d'erreur normalisé, helper `context`.
- [ ] **Phase 1 — parcours minimal** : `identifyPatients`,
      `getAppointmentByCode`, `getAppointmentById`, `checkInAppointment`
      (idempotence + proposedTicket + multi-RDV), `getNotificationReadiness`.
- [ ] **Phase 2 — routes de configuration** : les 6 GET `/config/*`
      (débloquent l'admin ApiBorne via la sonde).
- [ ] **Phase 3 — reste du contrat** : `updatePatient`, documents
      (list/upload/delete), `setAppointmentPrescriber`,
      `setAppointmentStatus`, `staffSignIn` — réels ou formes minimales (§4).
- [ ] **Phase 4 — canal sortant** (si la conf est portée par ApiBorne) :
      `issueForAppointment` + `appointmentStatusChanged` d'abord, puis
      `cancelForAppointment`, `getCheckinNotifySettings`, `printTicket`.
- [ ] **Phase 5 — vérification** (§9).

## 9. Définition de fini — tests à faire passer

1. `identify` sans headers → `401` corps normalisé (device validé en premier).
2. `identify` avec un NIR contenant des espaces → patient trouvé.
3. `identify` 0 résultat → `200 { "patients": [] }`.
4. `by-code` code inconnu → `404 UNKNOWN_APPOINTMENT`.
5. `check-in` avec `proposedTicket` → la réponse reprend ce ticket.
6. Le MÊME `check-in` rejoué → `200` avec le MÊME ticket (pas de doublon).
7. `check-in` d'un RDV terminé/annulé → `409 ALREADY_CHECKED_IN`.
8. Upload au-delà de votre limite → `413 UPLOAD_TOO_LARGE`.
9. `PUT status` en arrière (inCare → checkedIn) → `200` sans effet.
10. `staff/sign-in` mauvais identifiants → `401 INVALID_CREDENTIALS`.
11. Chaque `GET /config/*` → `200` avec l'enveloppe exacte attendue.
12. Preflight `OPTIONS` sur `identify` → headers CORS complets.

Outils : curl en local, puis ngrok (`ngrok http <port>`, URL https comme base
contrat dans l'admin ApiBorne) ; la sonde config-check de l'admin doit passer
au vert ; le banc de test « Test du contrat » de l'admin ApiBorne exécute
chaque route de lecture avec des entrées réelles et rend un rapport de
conformité. Guide : https://developers.apiborne.com/guide/tester/

## 10. Pièges connus (relire avant de livrer)

- `requiredDocumentTypes` en chaînes nues au lieu d'objets → types requis
  invisibles à la borne.
- 401 transformé en redirection de login → borne cassée.
- Check-in non idempotent → doublons de tickets au premier timeout réseau.
- `proposedTicket` rejeté en erreur → parcours bloqué au dernier écran.
- Ids de référentiels différents des ids portés par les RDV → ciblage admin
  inopérant, contrôle « mauvais lieu » faux.
- RDV `cancelled` renvoyés par identify → le patient déroule tout le parcours
  pour échouer au check-in.
- Rappeler le serveur ApiBorne pendant un parcours → interdit par contrat.
- Valider strictement le schéma du `context` → casse à la première évolution
  mineure (ignorer l'inconnu).
