openapi: 3.0.3

info:
  title: Nosho — API Doctolib (Booking)
  version: "1.0.0"
  description: |
    API d'orchestration Doctolib opérée par **Nosho**. Elle expose, derrière une
    interface HTTP stable, les opérations de prise de rendez-vous sur Doctolib
    (recherche de créneaux, réservation, confirmation, annulation, déplacement,
    gestion patient).

    L'infrastructure d'authentification Doctolib (sessions navigateur, OTP,
    découverte d'agendas, cache de créneaux) est hébergée et maintenue côté
    Nosho. Le partenaire consomme uniquement les routes décrites ci-dessous —
    il n'a jamais à manipuler les identifiants ou les sessions Doctolib.

    ## Concepts clés

    - **`profileName`** : adresse e-mail de scraping qui identifie un compte
      Doctolib (un établissement / cabinet). Fournie par Nosho lors de
      l'onboarding. Exemple : `centre.exemple@nosho.io`.
    - **`agendaId`** : identifiant Doctolib **de la plateforme** d'un agenda
      (entier numérique, ex. `12345`). C'est l'ID Doctolib, pas un ID interne
      Nosho. Les routes refusent (`400`) un `agendaId` inconnu pour le profil.
    - **Identifiant de rendez-vous signé** : Doctolib identifie un RDV par un
      jeton signé opaque commençant par `eyJ...`. C'est ce que renvoie `/book`
      et ce qu'attendent `/confirm`, `/cancel`, `/move`.
    - **Format date-heure des créneaux** (`/book`, `/move`) :
      `YYYY-MM-DD HH:MM:SS.000` en heure locale **Europe/Paris**
      (ex. `2026-04-07 14:30:00.000`).
    - **Format date des requêtes `/slots`** : `YYYY-MM-DD`.

    ## Codes d'erreur transverses

    - `400` — paramètres manquants ou invalides (ex. `agendaId` inconnu).
    - `401` — clé d'API absente ou invalide.
    - `503` — session Doctolib momentanément indisponible (en cours de
      ré-authentification). Respecter l'en-tête `Retry-After`.
    - `504` — délai dépassé sur un appel Doctolib live.
  contact:
    name: Nosho — Support technique
    email: jb@nosho.io

servers:
  - url: https://{host}
    description: Serveur Nosho (l'hôte exact et la clé d'API sont fournis à l'onboarding)
    variables:
      host:
        default: api.nosho.example
        description: Hôte fourni par Nosho

security:
  - BearerAuth: []

tags:
  - name: Disponibilité
    description: Sondes de santé (sans authentification).
  - name: Créneaux
    description: Lecture des créneaux disponibles et des motifs.
  - name: Rendez-vous
    description: Création, confirmation, annulation, déplacement de RDV.
  - name: Patients
    description: Recherche et création de patients.
  - name: Compte
    description: Métadonnées du compte Doctolib.

paths:

  /health:
    get:
      operationId: getHealth
      tags: [Disponibilité]
      summary: Sonde de santé
      description: |
        Retourne l'état du serveur. **Aucune authentification.** À utiliser pour
        du monitoring de disponibilité (uptime).
      security: []
      responses:
        "200":
          description: Serveur opérationnel
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: ok }
                  uptime_seconds: { type: integer, example: 38122 }
                  sessions:
                    type: object
                    properties:
                      active: { type: integer, example: 3 }
                      warming: { type: integer, example: 0 }

  /visit-motives:
    get:
      operationId: getVisitMotives
      tags: [Créneaux]
      summary: Motifs de consultation d'un agenda
      description: |
        Liste les motifs de consultation disponibles pour un agenda (lecture
        base de données — aucune session navigateur). C'est l'étape qui permet
        de récupérer un `platformMotiveId` à passer ensuite à `/book` comme
        `visitMotiveId`.
      parameters:
        - name: agendaId
          in: query
          required: true
          description: Identifiant Doctolib de l'agenda.
          schema: { type: string, example: "12345" }
      responses:
        "200":
          description: Liste des motifs
          content:
            application/json:
              schema:
                type: object
                properties:
                  motives:
                    type: array
                    items: { $ref: "#/components/schemas/VisitMotive" }
                  count: { type: integer, example: 36 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /account-info:
    get:
      operationId: getAccountInfo
      tags: [Compte]
      summary: Métadonnées du compte Doctolib
      description: |
        Retourne les métadonnées nécessaires à la création de RDV/patients :
        `patientBaseId`, `accountId`, et la liste des motifs (`visitMotives`).
        Nécessite qu'une session Doctolib ait déjà été initialisée côté Nosho
        pour ce profil (sinon `401 session_not_initialized`).
      parameters:
        - name: profileName
          in: query
          required: true
          schema: { type: string, example: centre.exemple@nosho.io }
        - name: agendaId
          in: query
          required: false
          description: Si absent, le premier agenda découvert du profil est utilisé.
          schema: { type: string, example: "12345" }
      responses:
        "200":
          description: Métadonnées du compte
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AccountInfo" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401":
          description: Session non initialisée ou clé invalide
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /slots:
    get:
      operationId: getSlots
      tags: [Créneaux]
      summary: Créneaux disponibles pour un agenda
      description: |
        Retourne les créneaux libres pour un agenda sur une fenêtre de dates.
        Sert depuis un cache fichier quand il est frais et couvre la fenêtre,
        sinon bascule sur un fetch live Doctolib. Le champ `source` indique
        l'origine (`cache` ou `live`).
      parameters:
        - name: profileName
          in: query
          required: true
          schema: { type: string, example: centre.exemple@nosho.io }
        - name: agendaId
          in: query
          required: true
          schema: { type: string, example: "12345" }
        - name: startDate
          in: query
          required: true
          description: Début de fenêtre (YYYY-MM-DD).
          schema: { type: string, format: date, example: "2026-04-03" }
        - name: endDate
          in: query
          required: true
          description: Fin de fenêtre (YYYY-MM-DD).
          schema: { type: string, format: date, example: "2026-04-10" }
        - name: slotDuration
          in: query
          required: false
          description: Durée d'un créneau en minutes (5 à 240). Défaut 15.
          schema: { type: integer, minimum: 5, maximum: 240, default: 15 }
      responses:
        "200":
          description: Liste des créneaux
          content:
            application/json:
              schema:
                type: object
                properties:
                  slots:
                    type: array
                    items: { $ref: "#/components/schemas/Slot" }
                  count: { type: integer, example: 12 }
                  source:
                    type: string
                    enum: [cache, live]
                    example: cache
                  scrapedAt:
                    type: string
                    nullable: true
                    format: date-time
                  ageMs: { type: integer, nullable: true }
                  generation: { type: integer, nullable: true }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/SessionUnavailable" }
        "504":
          description: Délai dépassé sur le fetch live
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }

  /book:
    post:
      operationId: bookAppointment
      tags: [Rendez-vous]
      summary: Réservation complète d'un rendez-vous
      description: |
        Réserve un créneau, recherche ou crée le patient, puis crée le RDV — en
        une seule opération. Si `patientBaseId` est absent, l'API tente de le
        résoudre automatiquement à partir des patients existants de l'agenda.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BookRequest" }
      responses:
        "200":
          description: RDV créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  appointment: { $ref: "#/components/schemas/Appointment" }
                  patient: { $ref: "#/components/schemas/DoctolibPatient" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/SessionUnavailable" }

  /confirm:
    post:
      operationId: confirmAppointment
      tags: [Rendez-vous]
      summary: Confirmer un rendez-vous
      description: |
        Confirme un RDV. Accepte un ID signé Doctolib (`eyJ...`) ou un ID
        numérique Nosho (résolu automatiquement côté serveur).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AppointmentActionRequest" }
      responses:
        "200":
          description: RDV confirmé
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppointmentActionResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AppointmentNotFound" }
        "409": { $ref: "#/components/responses/AmbiguousMatch" }
        "503": { $ref: "#/components/responses/SessionUnavailable" }

  /cancel:
    post:
      operationId: cancelAppointment
      tags: [Rendez-vous]
      summary: Annuler un rendez-vous
      description: |
        Annule un RDV. Accepte un ID signé Doctolib (`eyJ...`) ou un ID
        numérique Nosho (résolu automatiquement côté serveur).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AppointmentActionRequest" }
      responses:
        "200":
          description: RDV annulé
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppointmentActionResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/AppointmentNotFound" }
        "409": { $ref: "#/components/responses/AmbiguousMatch" }
        "503": { $ref: "#/components/responses/SessionUnavailable" }

  /move:
    post:
      operationId: moveAppointment
      tags: [Rendez-vous]
      summary: Déplacer un rendez-vous
      description: Déplace un RDV existant vers un nouveau créneau.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/MoveRequest" }
      responses:
        "200":
          description: RDV déplacé
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AppointmentActionResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/SessionUnavailable" }

  /patient/search:
    post:
      operationId: searchPatient
      tags: [Patients]
      summary: Rechercher un patient
      description: Recherche un patient par nom ou téléphone sur le compte Doctolib.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileName, query]
              properties:
                profileName: { type: string, example: centre.exemple@nosho.io }
                query: { type: string, example: Dupont }
                agendaId:
                  type: string
                  description: Optionnel. Défaut = premier agenda du profil.
                  example: "12345"
      responses:
        "200":
          description: Résultats de recherche
          content:
            application/json:
              schema:
                type: object
                properties:
                  patients:
                    type: array
                    items: { $ref: "#/components/schemas/DoctolibPatient" }
                  count: { type: integer, example: 2 }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /patient/create:
    post:
      operationId: createPatient
      tags: [Patients]
      summary: Créer un patient
      description: Crée un patient sur le compte Doctolib.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileName, patient, patientBaseId]
              properties:
                profileName: { type: string, example: centre.exemple@nosho.io }
                patient: { $ref: "#/components/schemas/PatientInput" }
                patientBaseId:
                  type: integer
                  description: Identifiant du registre patient (cf. /account-info).
                  example: 789
      responses:
        "200":
          description: Patient créé
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  patient: { $ref: "#/components/schemas/DoctolibPatient" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }

components:

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        Clé d'API fournie par Nosho, transmise via
        `Authorization: Bearer <clé>`. Une clé peut être révoquée à tout moment
        côté Nosho.

  responses:
    BadRequest:
      description: Paramètres manquants ou invalides
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
    Unauthorized:
      description: Clé d'API absente ou invalide
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: Unauthorized }
    AppointmentNotFound:
      description: Rendez-vous introuvable
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: appointment_not_found }
    AmbiguousMatch:
      description: Plusieurs RDV correspondent (résolution impossible sans ID signé)
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: ambiguous_appointment_match }
    SessionUnavailable:
      description: |
        Session Doctolib momentanément indisponible (ré-authentification en
        cours). Réessayer après le délai indiqué par `Retry-After`.
      headers:
        Retry-After:
          schema: { type: integer, example: 30 }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/Error" }
          example: { error: session_unavailable, reason: session_dead }

  schemas:

    Error:
      type: object
      properties:
        error: { type: string, example: "Required: profileName, agendaId" }
        reason: { type: string }

    VisitMotive:
      type: object
      properties:
        id:
          type: integer
          description: ID interne Nosho du motif.
          example: 457
        platformMotiveId:
          type: string
          description: ID Doctolib du motif — à passer à /book comme visitMotiveId.
          example: "14863828"
        name: { type: string, example: "Consultation de suivi" }
        durationMinutes: { type: integer, nullable: true, example: 20 }

    Slot:
      type: object
      properties:
        start:
          type: string
          description: Début, heure locale Paris (YYYY-MM-DD HH:MM:SS.000).
          example: "2026-04-07 14:30:00.000"
        end:
          type: string
          description: Fin, heure locale Paris.
          example: "2026-04-07 14:45:00.000"
        startISO: { type: string, format: date-time, example: "2026-04-07T12:30:00.000Z" }
        endISO: { type: string, format: date-time, example: "2026-04-07T12:45:00.000Z" }
        date: { type: string, description: Date formatée (FR), example: "07/04/2026" }
        time: { type: string, description: Heure formatée (FR), example: "14:30" }

    AccountInfo:
      type: object
      properties:
        patientBaseId: { type: integer, nullable: true, example: 789 }
        accountId: { type: integer, nullable: true, example: 1122 }
        visitMotives:
          type: array
          description: Motifs tels que renvoyés par Doctolib.
          items: { type: object, additionalProperties: true }
        discoveredAgendaIds:
          type: array
          items: { type: string }
          example: ["12345", "12346"]

    PatientInput:
      type: object
      required: [firstName, lastName, phoneNumber]
      properties:
        firstName: { type: string, example: Jean }
        lastName: { type: string, example: Dupont }
        phoneNumber: { type: string, example: "0612345678" }
        email: { type: string, format: email, nullable: true }
        birthdate:
          type: string
          format: date
          nullable: true
          example: "1980-05-12"

    DoctolibPatient:
      type: object
      description: Patient tel que renvoyé par Doctolib (champs principaux).
      properties:
        id:
          type: string
          description: ID signé Doctolib du patient (eyJ...).
          example: "eyJhbGciOi..."
        first_name: { type: string, example: Jean }
        last_name: { type: string, example: Dupont }
        patient_base_id: { type: integer, example: 789 }
        appointments_count: { type: integer, example: 3 }
      additionalProperties: true

    Appointment:
      type: object
      description: Rendez-vous tel que renvoyé par Doctolib (champs principaux).
      properties:
        id:
          type: string
          description: ID signé Doctolib du RDV (eyJ...) — à réutiliser pour confirm/cancel/move.
          example: "eyJhbGciOi..."
        status: { type: string, example: confirmed }
        start_date: { type: string, format: date-time, example: "2026-04-07T14:30:00.000+02:00" }
        end_date: { type: string, format: date-time, example: "2026-04-07T14:45:00.000+02:00" }
        agenda_id: { type: integer, example: 12345 }
      additionalProperties: true

    BookRequest:
      type: object
      required: [profileName, agendaId, startDate, endDate, patient]
      properties:
        profileName: { type: string, example: centre.exemple@nosho.io }
        agendaId: { type: string, example: "12345" }
        startDate:
          type: string
          description: Début du créneau, heure locale Paris (YYYY-MM-DD HH:MM:SS.000).
          example: "2026-04-07 14:30:00.000"
        endDate:
          type: string
          description: Fin du créneau, heure locale Paris.
          example: "2026-04-07 14:45:00.000"
        visitMotiveId:
          type: integer
          nullable: true
          description: ID Doctolib du motif (cf. platformMotiveId de /visit-motives).
          example: 14863828
        visitMotiveCategoryId:
          type: integer
          nullable: true
          example: 456
        patient: { $ref: "#/components/schemas/PatientInput" }
        notes:
          type: string
          nullable: true
          example: "Premier rendez-vous"
        patientBaseId:
          type: integer
          nullable: true
          description: Optionnel — résolu automatiquement si absent.
          example: 789

    MoveRequest:
      type: object
      required: [profileName, appointmentId, agendaId, newStartDate, newEndDate]
      properties:
        profileName: { type: string, example: centre.exemple@nosho.io }
        appointmentId:
          type: string
          description: ID signé Doctolib (eyJ...) ou ID numérique Nosho.
          example: "eyJhbGciOi..."
        agendaId: { type: string, example: "12345" }
        newStartDate:
          type: string
          description: Nouveau début, heure locale Paris (YYYY-MM-DD HH:MM:SS.000).
          example: "2026-04-08 10:00:00.000"
        newEndDate:
          type: string
          description: Nouvelle fin, heure locale Paris.
          example: "2026-04-08 10:15:00.000"
        visitMotiveId: { type: integer, nullable: true, example: 14863828 }
        patientId:
          type: string
          nullable: true
          description: ID signé Doctolib du patient.
          example: "eyJhbGciOi..."

    AppointmentActionRequest:
      type: object
      required: [profileName, appointmentId]
      properties:
        profileName: { type: string, example: centre.exemple@nosho.io }
        appointmentId:
          oneOf:
            - type: string
            - type: integer
          description: ID signé Doctolib (eyJ...) ou ID numérique Nosho.
          example: "eyJhbGciOi..."

    AppointmentActionResponse:
      type: object
      properties:
        success: { type: boolean, example: true }
        appointment:
          description: Représentation du RDV après l'opération (forme Doctolib).
          oneOf:
            - { $ref: "#/components/schemas/Appointment" }
            - type: object
              additionalProperties: true
