openapi: 3.1.0
info:
  title: DrApp Public API
  version: 1.0.0
  description: |
    API pública REST de DrApp para integrar aplicaciones externas con scheduling,
    pacientes, registros clínicos y más.

    ## Autenticación

    Todas las requests deben incluir el header:
    ```
    Authorization: Bearer drapp_live_<api_key>
    ```

    Para entorno sandbox:
    ```
    Authorization: Bearer drapp_sandbox_<api_key>
    ```

    Las API keys se generan desde el backoffice de DrApp en
    `Configuración → API → Generar clave`.

    ## Rate Limiting

    Cada clave tiene límites de requests por minuto y por día.
    Los headers de respuesta indican el estado actual:

    - `X-RateLimit-Limit-Minute` / `X-RateLimit-Remaining-Minute`
    - `X-RateLimit-Limit-Day` / `X-RateLimit-Remaining-Day`
    - `X-RateLimit-Burst` — cantidad de requests extra permitidas sobre el límite por minuto en un burst puntual (default: 10)
    - `X-RateLimit-Write-Limit` / `X-RateLimit-Write-Remaining` — límite separado para operaciones de escritura (POST/PATCH/PUT/DELETE), por defecto 33% del rpm

    **Burst allowance:** las requests pueden exceder el rpm configurado hasta por `burst_allowance`
    (default 10) requests adicionales en un mismo minuto, permitiendo picos puntuales de tráfico.

    **Write rate limit:** las operaciones de escritura (POST, PATCH, PUT, DELETE) tienen un límite
    separado más bajo (por defecto 33% del rpm) para proteger la integridad de los datos.

    Cuando se excede cualquier límite, la API responde `429 Too Many Requests` con el header
    `Retry-After` indicando los segundos a esperar antes de reintentar.

    ## Compresión

    Todas las respuestas se comprimen automáticamente con **gzip** cuando el cliente
    envía el header `Accept-Encoding: gzip`. Esto reduce significativamente el tamaño
    de las respuestas, especialmente en listados largos.

    ```
    Accept-Encoding: gzip
    ```

    La respuesta incluirá `Content-Encoding: gzip`. Todos los clientes HTTP modernos
    (curl, fetch, axios, etc.) manejan la descompresión de forma transparente.

    ## Formato de errores

    Todas las respuestas de error siguen un formato estandarizado:

    ```json
    {
      "error": "not_found",
      "message": "El recurso solicitado no existe",
      "status": 404,
      "request_id": "550e8400-e29b-41d4-a716-446655440000"
    }
    ```

    | Campo | Tipo | Descripción |
    |---|---|---|
    | `error` | string | Código de error máquina-legible (e.g. `rate_limit_exceeded`, `unauthorized`, `not_found`) |
    | `message` | string | Descripción legible del error |
    | `status` | integer | HTTP status code |
    | `request_id` | string | ID de la request para debugging (mismo que `X-Request-ID`) |

    Códigos de error comunes:

    | Status | Error | Descripción |
    |---|---|---|
    | 400 | `bad_request` | Request malformada o parámetros inválidos |
    | 401 | `unauthorized` | API key faltante o inválida |
    | 403 | `forbidden` | La API key no tiene el scope requerido |
    | 403 | `resource_required` | Se requiere identidad profesional (ver [Scopes profesionales](#scopes-profesionales)) |
    | 403 | `resource_mismatch` | El `resource.id` no coincide con el profesional vinculado a la API key |
    | 404 | `not_found` | Recurso no encontrado |
    | 409 | `conflict` | Conflicto (e.g. slot ya ocupado) |
    | 429 | `rate_limit_exceeded` | Límite de requests excedido |
    | 500 | `internal_error` | Error interno del servidor |

    ## Sandbox

    Las claves sandbox (`drapp_sandbox_*`) apuntan al team `drapp-sandbox`
    con datos de prueba. No afectan datos reales, no tienen rate limit y
    no se loguea su uso.

    ### App de demo pre-configurada

    Para facilitar las pruebas, el entorno sandbox incluye una app demo pre-aprobada:

    | Campo | Valor |
    |---|---|
    | **Client ID** | `drapp_demo_sandbox` |
    | **Client Secret** | `demo_sandbox_secret_not_for_production` |
    | **Scopes** | `scheduling:read`, `scheduling:write`, `patients:read`, `patients:write`, `identity:read`, `clinical:read`, `webhooks:read`, `webhooks:write` |

    Esta app permite explorar la API inmediatamente sin necesidad de crear una app ni
    esperar aprobación. Los datos del team `drapp-sandbox` incluyen profesionales,
    pacientes y turnos de ejemplo.

    ## Permisos (scopes)

    Cada app solicita uno o más scopes que determinan a qué datos puede acceder.
    Los scopes siguen el formato `recurso:acción`.

    | Scope | Descripción |
    |---|---|
    | `identity:read` | Consultar los profesionales (recursos) y servicios configurados en el equipo. |
    | `identity:write` | Crear y editar profesionales y servicios del equipo. |
    | `scheduling:read` | Ver turnos agendados y consultar disponibilidad horaria. |
    | `scheduling:write` | Agendar, reprogramar y cancelar turnos. |
    | `patients:read` | Buscar y ver datos de pacientes del equipo. |
    | `patients:write` | Crear nuevos pacientes y actualizar sus datos. |
    | `clinical:read` | Leer historia clínica, notas y registros de pacientes. |
    | `clinical:write` | Crear notas y registros en la historia clínica. |
    | `prescriptions:read` | Consultar recetas emitidas. |
    | `prescriptions:write` | Emitir nuevas recetas a nombre de un profesional. |
    | `comms:read` | Ver mensajes enviados por email. |
    | `comms:write` | Enviar mensajes a pacientes por email. |
    | `webhooks:read` | Listar webhooks registrados y su historial de entregas. |
    | `webhooks:write` | Crear, editar y eliminar webhooks. |

    Los scopes `:write` incluyen implícitamente el acceso `:read` del mismo recurso.

    ### Scopes profesionales

    Los scopes `clinical:write` y `prescriptions:write` son **scopes profesionales**: solo se
    otorgan cuando la API key fue creada por un usuario que tiene perfil de profesional
    (resource) en el equipo.

    **¿Por qué?** Crear registros clínicos y emitir recetas son actos médicos que requieren
    la identidad del profesional que los firma. Una secretaria o administrador que instale
    una app no debería poder firmar recetas ni escribir historia clínica a nombre de un médico.

    #### Comportamiento

    - **Profesional instala la app:** recibe todos los scopes solicitados, incluyendo los profesionales.
      La API key queda vinculada a su identidad (`installed_by_resource_id`), que se expone como
      `resource_id` en el endpoint `GET /me`.
    - **No-profesional instala la app:** los scopes `clinical:write` y `prescriptions:write` se
      excluyen automáticamente. La app funciona con el resto de los permisos. `GET /me` devuelve
      `resource_id: null`.
    - **Auto-fill de `resource.id`:** en los endpoints que requieren un profesional, si el body
      no incluye `resource.id`, se completa automáticamente con la identidad del profesional
      vinculado a la API key. Si se envía un `resource.id` diferente, se rechaza con `403 resource_mismatch`.
    - **Actualización posterior:** si un usuario que no era profesional al momento de instalar
      obtiene un perfil de profesional, puede actualizar su instalación con
      `POST /install/installations/:id/refresh-resource` para recuperar los scopes profesionales.

    #### Errores relacionados

    | Status | Error | Descripción |
    |---|---|---|
    | 403 | `resource_required` | El endpoint requiere identidad profesional pero la API key no tiene una vinculada. |
    | 403 | `resource_mismatch` | El `resource.id` del body no coincide con el profesional vinculado a la API key. |

    #### Endpoints afectados

    | Endpoint | Requiere profesional | Comportamiento |
    |---|---|---|
    | `POST /teams/:id/records` | Sí | 403 si no es profesional. Auto-fill `resource.id`. |
    | `POST /teams/:id/records/:id` | Sí | 403 si no es profesional. Auto-fill `resource.id`. |
    | `POST /teams/:id/prescriptions` | Sí | 403 si no es profesional. Auto-fill `resource.id`. |
    | `POST /teams/:id/consumers/:id/notes` | No | Auto-fill `resource.id` si hay profesional vinculado, pero no rechaza si no lo hay. |
    | `POST /teams/:id/events` | No | Sin cambios. Secretarias pueden crear turnos. |

    ## Firma de webhooks

    Los webhooks se firman con HMAC-SHA256. Verificar con:
    ```javascript
    const body = await request.text()
    const signature = 'sha256=' + crypto.createHmac('sha256', signingSecret)
      .update(body).digest('hex')
    const isValid = signature === request.headers['x-drapp-signature']
    ```

    Headers enviados en cada delivery:
    - `X-Drapp-Event` — tipo de evento (e.g. `event.created`)
    - `X-Drapp-Delivery` — UUID único por entrega
    - `X-Drapp-Signature` — `sha256=<hmac>`
    - `X-Drapp-Timestamp` — Unix timestamp de la entrega

    ### Reintentos automáticos

    Si una entrega falla (timeout o status >= 400), se reintenta automáticamente con el siguiente
    schedule: **1 min, 5 min, 30 min, 2 h, 24 h** (5 reintentos en total).

    Cada entrega tiene un estado de delivery:
    - `pending` — primer intento pendiente
    - `retrying` — falló y está en cola de reintentos
    - `delivered` — entregado exitosamente (status 2xx)
    - `exhausted` — todos los reintentos agotados

    Si un webhook acumula **10 fallos consecutivos**, se desactiva automáticamente (`active: false`).
    Puede reactivarse manualmente vía `PATCH /webhooks/:id` con `{ "active": true }`.

    ## API Versioning

    Todas las respuestas incluyen el header `X-API-Version: v1` indicando la versión actual de la API.

    Cuando un endpoint sea deprecado, se agregarán los headers `Deprecation` y `Sunset` según
    [RFC 8594](https://datatracker.ietf.org/doc/html/rfc8594), indicando la fecha a partir de la cual
    el endpoint dejará de estar disponible.

    ## Paginación por cursor

    Los endpoints que devuelven listas soportan paginación basada en cursor.
    Para obtener la siguiente página, pasar `?cursor=<opaque_token>` usando el valor
    de `next_cursor` de la respuesta anterior.

    Envelope de respuesta:
    ```json
    {
      "data": [...],
      "pagination": {
        "has_more": true,
        "next_cursor": "eyJpZCI6MTIzfQ=="
      }
    }
    ```

    - `has_more` — `true` si hay más resultados después de esta página
    - `next_cursor` — token opaco para la siguiente página (presente solo si `has_more` es `true`)
    - `limit` — cantidad máxima de resultados por página (max: 50, default: 20)

    Retrocompatibilidad: los parámetros `?offset=` y `?limit=` siguen funcionando,
    pero se recomienda migrar a cursores para mejor rendimiento y consistencia.

    ## Idempotencia

    Para evitar operaciones duplicadas (por ejemplo, al reintentar un request que dio timeout),
    las requests POST, PATCH y PUT pueden incluir el header `Idempotency-Key`:

    ```
    Idempotency-Key: mi-clave-unica-12345
    ```

    Si se envía una request con la misma key dentro de las **24 horas** siguientes,
    la API devuelve la respuesta original sin ejecutar la operación nuevamente.
    La respuesta incluye el header `X-Idempotent-Replayed: true` para indicar que es una respuesta cacheada.

    - La key es única por instalación (API key) — max 256 caracteres
    - Solo aplica a métodos mutantes (POST, PATCH, PUT)
    - Las requests GET no requieren ni soportan idempotency key

    ## Request ID

    Cada respuesta incluye el header `X-Request-ID` con un identificador único de la request.
    Si el cliente envía el header `X-Request-ID` en la request, se usa ese valor (truncado a 64 chars).
    Si no se envía, se genera un UUID v4 automáticamente.

    Útil para correlacionar requests con logs y debugging:
    ```
    X-Request-ID: 550e8400-e29b-41d4-a716-446655440000
    ```

    ## Filtros incrementales

    El endpoint `GET /teams/{teamID}/events` soporta filtros para sincronización incremental:

    - `?updated_after=2026-03-01T00:00:00Z` — solo eventos actualizados después de esa fecha
    - `?created_after=2026-03-01T00:00:00Z` — solo eventos creados después de esa fecha

    Ideal para integraciones que necesitan sincronizar cambios sin re-leer todo el dataset.

    ## Expand / Embed

    Por defecto, los objetos anidados en la respuesta de eventos (consumer, resource)
    se devuelven reducidos a `{ id }` para minimizar el payload.

    Para obtener los objetos completos, usar el parámetro `?expand`:
    ```
    GET /teams/{teamID}/events?from=2026-03-01&to=2026-03-31&expand=consumer,resource
    ```

    Valores posibles: `consumer`, `resource` (separados por coma).

    ## Selección de campos

    Para reducir el tamaño de la respuesta, usar el parámetro `?fields` con una lista
    separada por comas de los campos deseados:
    ```
    GET /teams/{teamID}/consumers?q=garcia&fields=id,firstName,lastName,emails
    ```

    - El campo `id` siempre se incluye aunque no se especifique
    - Aplica a cualquier endpoint que devuelva objetos JSON
    - En respuestas paginadas, filtra cada item del array `data`

    ## Validación de request body

    Todos los endpoints de escritura (POST/PATCH/PUT) validan el body antes de procesarlo.
    Si hay errores de validación, la respuesta es `400 Validation Error` con detalle por campo:

    ```json
    {
      "error": "validation_error",
      "message": "Validation failed: firstName is required; emails must be a array",
      "details": [
        { "field": "firstName", "message": "firstName is required" },
        { "field": "emails", "message": "emails must be a array" }
      ]
    }
    ```

    Validaciones comunes:
    - Tipos: `string`, `number`, `integer`, `boolean`, `array`, `object`, `iso8601`
    - Formatos: fechas ISO 8601, URLs https://, emails
    - Rangos: `minLength`, `maxLength`, `min`, `max`, `minItems`
    - Enums: valores permitidos (e.g. `gender` solo acepta `male`, `female`, `other`)

    ## Rotación de API keys

    Para rotar una API key sin downtime, usar el endpoint de rotación:

    ```
    POST /public/install/installations/:id/rotate
    Body: { "grace_hours": 24 }
    ```

    Esto genera una nueva key manteniendo la anterior activa durante el período de gracia
    (1 a 168 horas, default 24h). Las respuestas con la key vieja incluyen el header:
    ```
    X-API-Key-Deprecated: true; expires=2026-03-10T15:00:00.000Z
    ```

    Flujo recomendado:
    1. Llamar `/rotate` → recibir nueva key
    2. Desplegar la nueva key en tu aplicación
    3. Verificar que funciona
    4. La key vieja expira automáticamente

    ## Audit log

    El endpoint `GET /teams/{teamID}/audit-log` muestra todas las operaciones de escritura
    realizadas por cualquier app en el equipo. Ideal para auditoría y transparencia.

    Filtros disponibles:
    - `?action=create|update|delete`
    - `?resource=events|consumers|webhooks|records|notes|messages|prescriptions`
    - `?app_id=<uuid>` — filtrar por app específica
    - `?from=2026-03-01T00:00:00Z&to=2026-03-31T23:59:59Z` — rango de fechas

    ## Documentación interactiva

    - **Swagger UI:** `GET /public/v1/docs` — explorador interactivo de la API con "Try it out"
    - **Postman collection:** `GET /public/v1/postman.json` — importar directamente en Postman
    - **OpenAPI spec:** `GET /public/v1/openapi.yaml` — especificación YAML completa

    ## Developer Portal (registro de apps)

    Los developers externos pueden registrar apps mediante Auth0 JWT (mismo tenant que DrApp):

    ```
    GET    /public/developer/apps
    POST   /public/developer/apps
    GET    /public/developer/apps/:id
    PATCH  /public/developer/apps/:id
    GET    /public/developer/apps/:id/installations
    DELETE /public/developer/apps/:id/installations/:installId
    ```

    Una vez aprobada la app, los teams la instalan vía el consent screen:
    ```
    GET  /public/install/:clientId  → info de la app
    POST /public/install/:clientId  → confirmar instalación (devuelve api_key + install_token opcional)
    POST /public/install/exchange   → canjear install_token por la API key (server-to-server, sin auth)
    ```

    ## Token Exchange (flujo sin copy-paste)

    Si registraste una `post_install_url`, DrApp agrega automáticamente
    `?install_token=<TOKEN>&client_id=<CLIENT_ID>&team_id=<TEAM_ID>` al redirect post-instalación.
    Tu backend llama a `POST /public/install/exchange` con esos parámetros para obtener la API key
    directamente, sin que el usuario tenga que copiarla. El token expira a los 30 minutos y es de
    uso único. Si Redis no está disponible al instalar, el token se omite (fail-open) y el usuario
    ve la key manualmente como fallback.

servers:
  - url: https://api.drapp.com.ar/public/v1
    description: Producción
  - url: https://api.qa.drapp.com.ar/public/v1
    description: QA

security:
  - BearerAuth: []

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: drapp_live_<api_key>
    Auth0JWT:
      type: http
      scheme: bearer
      bearerFormat: Auth0 JWT (RS256)
      description: |
        Token Auth0 RS256 del developer o staff. Usado en endpoints /public/developer/...
        y /public/admin/... (no requiere API key de DrApp).

  schemas:
    Error:
      type: object
      properties:
        error:
          type: string
          example: "not_found"
        message:
          type: string
          example: "El recurso solicitado no existe"

    Resource:
      type: object
      properties:
        id:
          type: string
          example: resources/dr-garcia
        label:
          type: string
          example: Dr. García
        teamID:
          type: string
          example: teams/mi-clinica

    Service:
      type: object
      properties:
        id:
          type: string
          example: services/consulta-general
        key:
          type: string
          example: consulta-general
        label:
          type: string
          example: Consulta general
        duration:
          type: integer
          example: 30

    Event:
      type: object
      properties:
        id:
          type: string
          example: events/abc123
        teamID:
          type: string
        resource:
          $ref: '#/components/schemas/Resource'
        service:
          $ref: '#/components/schemas/Service'
        consumer:
          type: object
        status:
          type: string
          enum: [booked, fulfilled, cancelled, noshow, arrived, pending]
        day:
          type: string
          format: date
          example: "2026-03-15"
        time:
          type: string
          example: "10:30"
        startsAt:
          type: integer
          description: Unix timestamp en ms

    Consumer:
      type: object
      properties:
        id:
          type: string
          example: consumers/xyz789
        teamID:
          type: string
          example: teams/mi-clinica
        firstName:
          type: string
          example: María
        lastName:
          type: string
          example: López
        emails:
          type: array
          items:
            type: object
          example:
            - value: "maria.lopez@gmail.com"
              label: "personal"
        phones:
          type: array
          items:
            type: object
          example:
            - value: "+5491155678901"
              label: "celular"
        financiers:
          type: array
          items:
            type: object
          example:
            - name: "OSDE"
              plan: "310"
              memberId: "12345678"

    Webhook:
      type: object
      properties:
        id:
          type: string
          format: uuid
          example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
        team_id:
          type: string
          example: teams/mi-clinica
        url:
          type: string
          format: uri
          example: "https://mi-sistema.com/webhooks/drapp"
        events:
          type: array
          items:
            type: string
            enum: [event.created, event.updated, event.cancelled, consumer.created, consumer.updated, reminder.sent]
          example: ["event.created", "event.cancelled"]
          description: |
            Eventos disponibles:
            - `event.created` — nuevo turno agendado
            - `event.updated` — turno modificado. Incluye reprogramaciones: comparar `start`/`end` con el valor previo para detectar cambio de horario.
            - `event.cancelled` — turno cancelado
            - `consumer.created` — nuevo paciente creado
            - `consumer.updated` — datos de paciente modificados
            - `reminder.sent` — recordatorio enviado al paciente (WhatsApp/email)
        active:
          type: boolean
          example: true
        failure_count:
          type: integer
          example: 0
        created_at:
          type: string
          format: date-time
          example: "2026-03-01T14:30:00Z"

  parameters:
    teamID:
      name: teamID
      in: path
      required: true
      schema:
        type: string
        example: mi-clinica
      description: Slug del team (sin el prefijo `teams/`)
    fields:
      name: fields
      in: query
      schema:
        type: string
      description: "Campos a incluir en la respuesta (separados por coma). `id` se incluye siempre."
      example: "id,firstName,lastName"
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      schema:
        type: string
        maxLength: 256
      description: "Clave de idempotencia (max 256 chars). Si se repite la request con la misma key dentro de 24h, se devuelve la respuesta original sin re-ejecutar."

paths:

  # === GENERAL ===

  /me:
    get:
      tags: [General]
      summary: Información de la instalación autenticada
      description: |
        Devuelve los datos asociados a la API key usada en la request.
        Útil para validar que la key es correcta y ver a qué team, scopes y profesional tiene acceso.

        El campo `resource_id` indica el ID del profesional que instaló la app. Es `null` cuando
        la instalación fue hecha por un usuario sin perfil de profesional (ej: una secretaria o
        administrador). Los endpoints que requieren firma profesional (`clinical:write`,
        `prescriptions:write`) solo están disponibles cuando `resource_id` no es `null`.
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Datos de la instalación
          content:
            application/json:
              schema:
                type: object
                properties:
                  team_id:
                    type: string
                    example: teams/mi-clinica
                    description: ID del team asociado a esta API key
                  team_name:
                    type: string
                    example: Mi Clínica
                    description: Nombre legible del team
                  is_sandbox:
                    type: boolean
                    description: "`true` si la key es sandbox (`drapp_sandbox_*`)"
                  granted_scopes:
                    type: array
                    items:
                      type: string
                    example: ["scheduling:read", "patients:read"]
                    description: Scopes otorgados a esta instalación
                  resource_id:
                    type: string
                    nullable: true
                    example: resources/dr-gomez
                    description: |
                      ID del profesional vinculado a esta API key (`installed_by_resource_id`).
                      `null` si la app fue instalada por un usuario sin perfil de profesional.
                      Presente cuando `clinical:write` o `prescriptions:write` están en `granted_scopes`.
              example:
                team_id: "teams/clinica-norte"
                team_name: "Clínica Norte"
                is_sandbox: false
                resource_id: "resources/dr-gomez"
                granted_scopes:
                  - "identity:read"
                  - "scheduling:read"
                  - "scheduling:write"
                  - "patients:read"
                  - "patients:write"
                  - "clinical:write"
                  - "prescriptions:write"
                  - "webhooks:read"
                  - "webhooks:write"
        '401':
          description: API key inválida o no provista

  # === IDENTITY ===

  /teams/{teamID}/resources:
    get:
      tags: [Identity]
      summary: Listar recursos (profesionales) del equipo
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
            example: mi-clinica
        - name: q
          in: query
          schema:
            type: string
          description: Filtro de búsqueda por nombre
          example: "García"
      responses:
        '200':
          description: Lista de recursos
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Resource'
              example:
                - id: "resources/dr-garcia"
                  label: "Dr. García"
                  teamID: "teams/clinica-norte"
                - id: "resources/dra-martinez"
                  label: "Dra. Martínez"
                  teamID: "teams/clinica-norte"

  /teams/{teamID}/resources/{resourceID}:
    get:
      tags: [Identity]
      summary: Obtener recurso por ID
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: resourceID
          in: path
          required: true
          schema:
            type: string
          example: dr-garcia
      responses:
        '200':
          description: Recurso
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Resource'
              example:
                id: "resources/dr-garcia"
                label: "Dr. García"
                teamID: "teams/clinica-norte"

  /teams/{teamID}/services:
    get:
      tags: [Identity]
      summary: Listar servicios del equipo
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
      responses:
        '200':
          description: Lista de servicios
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Service'
              example:
                - id: "services/consulta-general"
                  key: "consulta-general"
                  label: "Consulta general"
                  duration: 30
                - id: "services/cardiologia"
                  key: "cardiologia"
                  label: "Cardiología"
                  duration: 45
                - id: "services/control-pediatrico"
                  key: "control-pediatrico"
                  label: "Control pediátrico"
                  duration: 20

  # === SCHEDULING ===

  /teams/{teamID}/events:
    get:
      tags: [Scheduling]
      summary: Listar turnos del equipo
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: from
          in: query
          schema:
            type: string
            format: date
          description: Fecha inicio (YYYY-MM-DD)
          example: "2026-03-01"
        - name: to
          in: query
          schema:
            type: string
            format: date
          description: Fecha fin (YYYY-MM-DD)
          example: "2026-03-31"
        - name: resource
          in: query
          schema:
            type: string
          description: "ID del recurso (ej: resources/dr-garcia)"
          example: "resources/dr-garcia"
        - name: status
          in: query
          schema:
            type: string
            enum: [booked, fulfilled, cancelled, noshow]
          example: "booked"
        - name: updated_after
          in: query
          schema:
            type: string
            format: date-time
          description: "Solo eventos actualizados después de esta fecha (ISO 8601). Útil para sync incremental."
          example: "2026-03-01T00:00:00Z"
        - name: created_after
          in: query
          schema:
            type: string
            format: date-time
          description: "Solo eventos creados después de esta fecha (ISO 8601)."
          example: "2026-03-01T00:00:00Z"
        - name: expand
          in: query
          schema:
            type: string
          description: "Objetos anidados a expandir (separados por coma). Sin expand, consumer y resource se reducen a `{id}`. Valores: `consumer`, `resource`."
          example: "consumer,resource"
        - name: fields
          in: query
          schema:
            type: string
          description: "Campos a incluir en cada objeto de la respuesta (separados por coma). El campo `id` se incluye siempre."
          example: "id,status,day,time,consumer"
      responses:
        '200':
          description: Lista de turnos
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Event'
              example:
                - id: "events/abc123"
                  teamID: "teams/clinica-norte"
                  resource:
                    id: "resources/dr-garcia"
                  service:
                    id: "services/consulta-general"
                    key: "consulta-general"
                    label: "Consulta general"
                    duration: 30
                  consumer:
                    id: "consumers/xyz789"
                  status: "booked"
                  day: "2026-03-15"
                  time: "10:30"
                  startsAt: 1773843000000
                - id: "events/def456"
                  teamID: "teams/clinica-norte"
                  resource:
                    id: "resources/dra-martinez"
                  service:
                    id: "services/cardiologia"
                    key: "cardiologia"
                    label: "Cardiología"
                    duration: 45
                  consumer:
                    id: "consumers/jkl012"
                  status: "fulfilled"
                  day: "2026-03-14"
                  time: "15:00"
                  startsAt: 1773774000000

    post:
      tags: [Scheduling]
      summary: Crear un turno
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [resource, service, consumer, day, time]
              properties:
                resource:
                  type: object
                  properties:
                    id:
                      type: string
                service:
                  type: object
                  properties:
                    key:
                      type: string
                consumer:
                  type: object
                day:
                  type: string
                  format: date
                time:
                  type: string
                  example: "10:30"
            example:
              resource:
                id: "resources/dr-garcia"
              service:
                key: "consulta-general"
              consumer:
                id: "consumers/xyz789"
              day: "2026-04-10"
              time: "09:00"
      responses:
        '200':
          description: Turno creado
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Event'
              example:
                id: "events/ghi789"
                teamID: "teams/clinica-norte"
                resource:
                  id: "resources/dr-garcia"
                  label: "Dr. García"
                  teamID: "teams/clinica-norte"
                service:
                  id: "services/consulta-general"
                  key: "consulta-general"
                  label: "Consulta general"
                  duration: 30
                consumer:
                  id: "consumers/xyz789"
                  firstName: "María"
                  lastName: "López"
                status: "booked"
                day: "2026-04-10"
                time: "09:00"
                startsAt: 1776078000000

  /teams/{teamID}/events/{eventID}:
    get:
      tags: [Scheduling]
      summary: Obtener turno por ID
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: eventID
          in: path
          required: true
          schema:
            type: string
          example: abc123
      responses:
        '200':
          description: Turno
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Event'
              example:
                id: "events/abc123"
                teamID: "teams/clinica-norte"
                resource:
                  id: "resources/dr-garcia"
                  label: "Dr. García"
                  teamID: "teams/clinica-norte"
                service:
                  id: "services/consulta-general"
                  key: "consulta-general"
                  label: "Consulta general"
                  duration: 30
                consumer:
                  id: "consumers/xyz789"
                  firstName: "María"
                  lastName: "López"
                status: "booked"
                day: "2026-03-15"
                time: "10:30"
                startsAt: 1773843000000

    post:
      tags: [Scheduling]
      summary: Actualizar un turno
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: eventID
          in: path
          required: true
          schema:
            type: string
          example: abc123
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
            example:
              status: "fulfilled"
      responses:
        '200':
          description: Turno actualizado
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Event'
              example:
                id: "events/abc123"
                teamID: "teams/clinica-norte"
                resource:
                  id: "resources/dr-garcia"
                  label: "Dr. García"
                  teamID: "teams/clinica-norte"
                service:
                  id: "services/consulta-general"
                  key: "consulta-general"
                  label: "Consulta general"
                  duration: 30
                consumer:
                  id: "consumers/xyz789"
                  firstName: "María"
                  lastName: "López"
                status: "fulfilled"
                day: "2026-03-15"
                time: "10:30"
                startsAt: 1773843000000

    delete:
      tags: [Scheduling]
      summary: Cancelar un turno
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: eventID
          in: path
          required: true
          schema:
            type: string
          example: abc123
      responses:
        '200':
          description: Turno cancelado
          content:
            application/json:
              example:
                id: "events/abc123"
                status: "cancelled"

  /teams/{teamID}/availability:
    get:
      tags: [Scheduling]
      summary: Consultar disponibilidad
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: from
          in: query
          schema:
            type: string
            format: date
          example: "2026-04-01"
        - name: to
          in: query
          schema:
            type: string
            format: date
          example: "2026-04-07"
        - name: resource
          in: query
          schema:
            type: string
          example: "resources/dr-garcia"
        - name: service
          in: query
          schema:
            type: string
          description: "Key del servicio (ej: consulta-general)"
          example: "consulta-general"
        - name: financier
          in: query
          schema:
            type: string
          example: "OSDE"
      responses:
        '200':
          description: Slots disponibles
          content:
            application/json:
              example:
                - date: "2026-04-01"
                  resource:
                    id: "resources/dr-garcia"
                    label: "Dr. García"
                  slots:
                    - time: "09:00"
                      available: true
                    - time: "09:30"
                      available: true
                    - time: "10:00"
                      available: false
                    - time: "10:30"
                      available: true
                - date: "2026-04-02"
                  resource:
                    id: "resources/dr-garcia"
                    label: "Dr. García"
                  slots:
                    - time: "14:00"
                      available: true
                    - time: "14:30"
                      available: true

  /teams/{teamID}/availability/{resourceID}/{date}:
    get:
      tags: [Scheduling]
      summary: Disponibilidad de un recurso en una fecha
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: resourceID
          in: path
          required: true
          schema:
            type: string
          example: dr-garcia
        - name: date
          in: path
          required: true
          schema:
            type: string
            format: date
          example: "2026-04-01"
        - name: service
          in: query
          schema:
            type: string
          example: "consulta-general"
      responses:
        '200':
          description: Slots disponibles para el recurso en esa fecha
          content:
            application/json:
              example:
                date: "2026-04-01"
                resource:
                  id: "resources/dr-garcia"
                  label: "Dr. García"
                slots:
                  - time: "09:00"
                    available: true
                  - time: "09:30"
                    available: true
                  - time: "10:00"
                    available: false
                  - time: "10:30"
                    available: true
                  - time: "11:00"
                    available: true

  # === PATIENTS ===

  /teams/{teamID}/consumers:
    get:
      tags: [Patients]
      summary: Buscar pacientes del equipo
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: q
          in: query
          schema:
            type: string
          description: Búsqueda fuzzy por nombre, apellido o DNI. Para búsqueda exacta por email o teléfono usar los params dedicados (email, phone).
          example: "López"
        - name: cursor
          in: query
          schema:
            type: string
          description: Token opaco para obtener la siguiente página de resultados (ver sección Paginación por cursor)
          example: "eyJpZCI6MTIzfQ=="
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 50
          description: Cantidad máxima de resultados por página (max 50)
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
          description: "Offset numérico (deprecado, preferir cursor)"
        - name: email
          in: query
          schema:
            type: string
            format: email
          description: Búsqueda exacta por email (case-insensitive). Mutuamente excluyente con q y phone.
          example: "paciente@example.com"
        - name: phone
          in: query
          schema:
            type: string
          description: Búsqueda exacta por teléfono (se normalizan los no-dígitos). Mutuamente excluyente con q y email.
          example: "+5491112345678"
      responses:
        '200':
          description: Lista de pacientes
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Consumer'
              example:
                data:
                  - id: "consumers/xyz789"
                    teamID: "teams/clinica-norte"
                    firstName: "María"
                    lastName: "López"
                    emails:
                      - value: "maria.lopez@gmail.com"
                        label: "personal"
                    phones:
                      - value: "+5491155678901"
                        label: "celular"
                    financiers:
                      - name: "OSDE"
                        plan: "310"
                        memberId: "12345678"
                  - id: "consumers/abc456"
                    teamID: "teams/clinica-norte"
                    firstName: "Carlos"
                    lastName: "López Hernández"
                    emails:
                      - value: "carlos.lopez@hotmail.com"
                        label: "personal"
                    phones:
                      - value: "+5491143210987"
                        label: "celular"
                    financiers: []
                pagination:
                  has_more: true
                  next_cursor: "eyJpZCI6NDU2fQ=="

    post:
      tags: [Patients]
      summary: Crear un paciente
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                firstName:
                  type: string
                lastName:
                  type: string
                emails:
                  type: array
                  items:
                    type: object
                    properties:
                      value:
                        type: string
                        format: email
                phones:
                  type: array
                  items:
                    type: object
                    properties:
                      value:
                        type: string
            example:
              firstName: "Alejandro"
              lastName: "Fernández"
              emails:
                - value: "alejandro.fernandez@gmail.com"
              phones:
                - value: "+5491167890123"
      responses:
        '201':
          description: Paciente creado
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Consumer'
              example:
                id: "consumers/mno345"
                teamID: "teams/clinica-norte"
                firstName: "Alejandro"
                lastName: "Fernández"
                emails:
                  - value: "alejandro.fernandez@gmail.com"
                    label: "personal"
                phones:
                  - value: "+5491167890123"
                    label: "celular"
                financiers: []

  /teams/{teamID}/consumers/validate:
    post:
      tags: [Patients]
      summary: Verificar duplicados antes de crear un paciente
      description: |
        Detecta si ya existe un paciente con el mismo email, teléfono o DNI.
        Útil para evitar duplicados antes de llamar a `POST /consumers`.
        Devuelve `{ valid: true }` si no hay conflictos, o la lista de matches si los hay.
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                firstName:
                  type: string
                lastName:
                  type: string
                emails:
                  type: array
                  items:
                    type: object
                    properties:
                      value:
                        type: string
                        format: email
                phones:
                  type: array
                  items:
                    type: object
                    properties:
                      value:
                        type: string
                identification:
                  type: string
                  description: DNI u otro documento de identidad
            example:
              firstName: "María"
              lastName: "López"
              emails:
                - value: "maria.lopez@gmail.com"
              phones:
                - value: "+5491155678901"
              identification: "30567890"
      responses:
        '200':
          description: Resultado de la validación
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    properties:
                      valid:
                        type: boolean
                        example: true
                    description: Sin conflictos — se puede crear el paciente
                  - type: array
                    items:
                      $ref: '#/components/schemas/Consumer'
                    description: Lista de pacientes existentes que coinciden
              examples:
                sin_duplicados:
                  summary: Sin conflictos
                  value:
                    valid: true
                con_duplicados:
                  summary: Se encontraron pacientes existentes con datos similares
                  value:
                    - id: "consumers/xyz789"
                      teamID: "teams/clinica-norte"
                      firstName: "María"
                      lastName: "López"
                      emails:
                        - value: "maria.lopez@gmail.com"
                          label: "personal"
                      phones:
                        - value: "+5491155678901"
                          label: "celular"

  /teams/{teamID}/consumers/{consumerID}:
    get:
      tags: [Patients]
      summary: Obtener paciente por ID
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: consumerID
          in: path
          required: true
          schema:
            type: string
          example: xyz789
      responses:
        '200':
          description: Paciente
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Consumer'
              example:
                id: "consumers/xyz789"
                teamID: "teams/clinica-norte"
                firstName: "María"
                lastName: "López"
                emails:
                  - value: "maria.lopez@gmail.com"
                    label: "personal"
                phones:
                  - value: "+5491155678901"
                    label: "celular"
                financiers:
                  - name: "OSDE"
                    plan: "310"
                    memberId: "12345678"

    post:
      tags: [Patients]
      summary: Actualizar un paciente
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: consumerID
          in: path
          required: true
          schema:
            type: string
          example: xyz789
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
            example:
              firstName: "María Elena"
              phones:
                - value: "+5491155678901"
                  label: "celular"
                - value: "+541143210000"
                  label: "trabajo"
              financiers:
                - name: "Swiss Medical"
                  plan: "SMG 20"
                  memberId: "87654321"
      responses:
        '200':
          description: Paciente actualizado
          content:
            application/json:
              example:
                id: "consumers/xyz789"
                teamID: "teams/clinica-norte"
                firstName: "María Elena"
                lastName: "López"
                emails:
                  - value: "maria.lopez@gmail.com"
                    label: "personal"
                phones:
                  - value: "+5491155678901"
                    label: "celular"
                  - value: "+541143210000"
                    label: "trabajo"
                financiers:
                  - name: "Swiss Medical"
                    plan: "SMG 20"
                    memberId: "87654321"

  /teams/{teamID}/consumers/{consumerID}/events:
    get:
      tags: [Patients]
      summary: Historial de turnos de un paciente
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: consumerID
          in: path
          required: true
          schema:
            type: string
          example: xyz789
      responses:
        '200':
          description: Turnos del paciente
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Event'
              example:
                - id: "events/abc123"
                  teamID: "teams/clinica-norte"
                  resource:
                    id: "resources/dr-garcia"
                  service:
                    key: "consulta-general"
                    label: "Consulta general"
                  status: "fulfilled"
                  day: "2026-02-20"
                  time: "10:30"
                - id: "events/def456"
                  teamID: "teams/clinica-norte"
                  resource:
                    id: "resources/dra-martinez"
                  service:
                    key: "cardiologia"
                    label: "Cardiología"
                  status: "booked"
                  day: "2026-04-10"
                  time: "15:00"

  /teams/{teamID}/consumers/{consumerID}/stats:
    get:
      tags: [Patients]
      summary: Estadísticas de turnos de un paciente
      description: |
        Devuelve contadores de turnos por estado para el paciente.
        Útil para mostrar en un perfil de paciente cuántos turnos ha tenido.
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: consumerID
          in: path
          required: true
          schema:
            type: string
          example: xyz789
      responses:
        '200':
          description: Estadísticas del paciente
          content:
            application/json:
              schema:
                type: object
                properties:
                  total:
                    type: integer
                    description: Total de turnos
                  booked:
                    type: integer
                  fulfilled:
                    type: integer
                  cancelled:
                    type: integer
                  noshow:
                    type: integer
              example:
                total: 24
                booked: 1
                fulfilled: 18
                cancelled: 3
                noshow: 2

  # === CLINICAL ===

  /teams/{teamID}/records/{recordID}:
    get:
      tags: [Clinical]
      summary: Obtener registro clínico por ID
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: recordID
          in: path
          required: true
          schema:
            type: string
          example: rec-001
      responses:
        '200':
          description: Registro clínico
          content:
            application/json:
              example:
                id: "records/rec-001"
                teamID: "teams/clinica-norte"
                consumer:
                  id: "consumers/xyz789"
                resource:
                  id: "resources/dr-garcia"
                type: "anamnesis"
                content:
                  motivo: "Control anual"
                  antecedentes: "Hipertensión arterial en tratamiento"
                  examen_fisico: "TA 120/80, FC 72, peso 75kg"
                createdAt: "2026-03-10T14:30:00Z"

    post:
      tags: [Clinical]
      summary: Actualizar registro clínico
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: recordID
          in: path
          required: true
          schema:
            type: string
          example: rec-001
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
            example:
              content:
                motivo: "Control anual"
                antecedentes: "Hipertensión arterial en tratamiento"
                examen_fisico: "TA 118/76, FC 70, peso 74kg"
                indicaciones: "Continuar medicación actual"
      responses:
        '200':
          description: Registro actualizado
          content:
            application/json:
              example:
                id: "records/rec-001"
                teamID: "teams/clinica-norte"
                consumer:
                  id: "consumers/xyz789"
                resource:
                  id: "resources/dr-garcia"
                type: "anamnesis"
                content:
                  motivo: "Control anual"
                  antecedentes: "Hipertensión arterial en tratamiento"
                  examen_fisico: "TA 118/76, FC 70, peso 74kg"
                  indicaciones: "Continuar medicación actual"
                createdAt: "2026-03-10T14:30:00Z"

  /teams/{teamID}/records:
    post:
      tags: [Clinical]
      summary: Crear registro clínico
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
            example:
              consumer:
                id: "consumers/xyz789"
              resource:
                id: "resources/dr-garcia"
              type: "anamnesis"
              content:
                motivo: "Dolor torácico"
                antecedentes: "Sin antecedentes relevantes"
                examen_fisico: "TA 130/85, FC 80, auscultación cardíaca normal"
      responses:
        '200':
          description: Registro creado
          content:
            application/json:
              example:
                id: "records/rec-002"
                teamID: "teams/clinica-norte"
                consumer:
                  id: "consumers/xyz789"
                resource:
                  id: "resources/dr-garcia"
                type: "anamnesis"
                content:
                  motivo: "Dolor torácico"
                  antecedentes: "Sin antecedentes relevantes"
                  examen_fisico: "TA 130/85, FC 80, auscultación cardíaca normal"
                createdAt: "2026-03-15T10:45:00Z"

  /teams/{teamID}/consumers/{consumerID}/records:
    get:
      tags: [Clinical]
      summary: Registros clínicos de un paciente
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: consumerID
          in: path
          required: true
          schema:
            type: string
          example: xyz789
        - name: type
          in: query
          schema:
            type: string
          description: Tipo de registro (ej. anamnesis, prescription). Sin filtro devuelve todos.
          example: "anamnesis"
      responses:
        '200':
          description: Registros clínicos del paciente
          content:
            application/json:
              example:
                - id: "records/rec-001"
                  type: "anamnesis"
                  resource:
                    id: "resources/dr-garcia"
                  content:
                    motivo: "Control anual"
                    examen_fisico: "TA 120/80, FC 72"
                  createdAt: "2026-03-10T14:30:00Z"
                - id: "records/rec-002"
                  type: "anamnesis"
                  resource:
                    id: "resources/dr-garcia"
                  content:
                    motivo: "Dolor torácico"
                    examen_fisico: "TA 130/85, FC 80"
                  createdAt: "2026-03-15T10:45:00Z"

  /teams/{teamID}/consumers/{consumerID}/notes:
    get:
      tags: [Clinical]
      summary: Notas del paciente
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: consumerID
          in: path
          required: true
          schema:
            type: string
          example: xyz789
      responses:
        '200':
          description: Lista de notas
          content:
            application/json:
              example:
                - id: "notes/n-001"
                  text: "Paciente alérgica a penicilina. Recordar para futuras prescripciones."
                  createdAt: "2026-02-15T09:00:00Z"
                - id: "notes/n-002"
                  text: "Solicitar análisis de sangre completo en próxima visita."
                  createdAt: "2026-03-10T14:35:00Z"

    post:
      tags: [Clinical]
      summary: Crear nota sobre un paciente
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: consumerID
          in: path
          required: true
          schema:
            type: string
          example: xyz789
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
            example:
              text: "Paciente refiere mejoría con el tratamiento actual. Continuar misma medicación."
      responses:
        '200':
          description: Nota creada
          content:
            application/json:
              example:
                id: "notes/n-003"
                text: "Paciente refiere mejoría con el tratamiento actual. Continuar misma medicación."
                createdAt: "2026-03-15T11:00:00Z"

  # === COMMS ===

  /teams/{teamID}/messages:
    get:
      tags: [Comms]
      summary: Listar mensajes del equipo
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
      responses:
        '200':
          description: Lista de mensajes
          content:
            application/json:
              example:
                - id: "messages/msg-001"
                  channel: "email"
                  consumer:
                    id: "consumers/xyz789"
                    firstName: "María"
                    lastName: "López"
                  text: "Recordatorio: tiene turno mañana 15/03 a las 10:30 con Dr. García."
                  status: "delivered"
                  sentAt: "2026-03-14T10:00:00Z"
                - id: "messages/msg-002"
                  channel: "email"
                  consumer:
                    id: "consumers/abc456"
                    firstName: "Carlos"
                    lastName: "López Hernández"
                  text: "Confirmación de turno para el 20/03 a las 15:00."
                  status: "sent"
                  sentAt: "2026-03-13T09:00:00Z"

    post:
      tags: [Comms]
      summary: Enviar un mensaje por email
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
            example:
              type: "email"
              to: "maria@example.com"
              content: "Hola María, le recordamos que tiene turno el 15/03 a las 10:30 con Dr. García en Clínica Norte."
      responses:
        '200':
          description: Mensaje enviado
          content:
            application/json:
              example:
                id: "messages/msg-003"
                channel: "email"
                consumer:
                  id: "consumers/xyz789"
                text: "Hola María, le recordamos que tiene turno el 15/03 a las 10:30 con Dr. García en Clínica Norte."
                status: "queued"
                sentAt: "2026-03-14T10:30:00Z"

  # === WEBHOOKS ===

  /teams/{teamID}/webhooks:
    get:
      tags: [Webhooks]
      summary: Listar webhooks registrados
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
      responses:
        '200':
          description: Lista de webhooks
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/Webhook'
              example:
                - id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                  team_id: "teams/clinica-norte"
                  url: "https://mi-sistema.com/webhooks/drapp"
                  events: ["event.created", "event.cancelled"]
                  active: true
                  failure_count: 0
                  created_at: "2026-03-01T14:30:00Z"
                - id: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
                  team_id: "teams/clinica-norte"
                  url: "https://crm.clinica-norte.com/api/drapp-events"
                  events: ["consumer.created", "consumer.updated"]
                  active: true
                  failure_count: 0
                  created_at: "2026-03-05T09:15:00Z"

    post:
      tags: [Webhooks]
      summary: Registrar un webhook
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  description: "Debe ser https://"
                events:
                  type: array
                  items:
                    type: string
                    enum: [event.created, event.updated, event.cancelled, consumer.created, consumer.updated, reminder.sent]
                secret:
                  type: string
                  description: Si no se provee, se genera automáticamente
            example:
              url: "https://mi-sistema.com/webhooks/drapp"
              events: ["event.created", "event.updated", "event.cancelled"]
      responses:
        '201':
          description: |
            Webhook creado. El campo `signing_secret` se devuelve SOLO en esta respuesta.
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Webhook'
                  - type: object
                    properties:
                      signing_secret:
                        type: string
                        description: Guardar de forma segura. No se mostrará nuevamente.
              example:
                id: "c3d4e5f6-a7b8-9012-cdef-123456789012"
                team_id: "teams/clinica-norte"
                url: "https://mi-sistema.com/webhooks/drapp"
                events: ["event.created", "event.updated", "event.cancelled"]
                active: true
                failure_count: 0
                signing_secret: "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6"
                created_at: "2026-03-15T12:00:00Z"

  /teams/{teamID}/webhooks/{webhookID}:
    patch:
      tags: [Webhooks]
      summary: Actualizar un webhook (url, events, re-activar)
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: webhookID
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                url:
                  type: string
                  format: uri
                events:
                  type: array
                  items:
                    type: string
                active:
                  type: boolean
                  description: Usar `true` para re-activar un webhook desactivado por fallos
            example:
              url: "https://mi-sistema-v2.com/webhooks/drapp"
              events: ["event.created", "event.updated", "event.cancelled", "consumer.created"]
              active: true
      responses:
        '200':
          description: Webhook actualizado
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Webhook'
              example:
                id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                team_id: "teams/clinica-norte"
                url: "https://mi-sistema-v2.com/webhooks/drapp"
                events: ["event.created", "event.updated", "event.cancelled", "consumer.created"]
                active: true
                failure_count: 0
                created_at: "2026-03-01T14:30:00Z"

    delete:
      tags: [Webhooks]
      summary: Eliminar un webhook
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: webhookID
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
      responses:
        '200':
          description: Webhook eliminado
          content:
            application/json:
              example:
                ok: true

  /teams/{teamID}/webhooks/{webhookID}/test:
    post:
      tags: [Webhooks]
      summary: Enviar webhook de prueba
      description: |
        Envía un evento `test` al URL configurado del webhook para verificar que la
        integración funciona correctamente. El payload de prueba incluye una firma
        HMAC-SHA256 válida igual que los eventos reales.
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: webhookID
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "d4e5f6a7-b8c9-0d1e-2f3a-4b5c6d7e8f9a"
      responses:
        '200':
          description: Resultado de la entrega de prueba
          content:
            application/json:
              example:
                delivered: true
                response_status: 200
                event_id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
              schema:
                type: object
                properties:
                  delivered:
                    type: boolean
                    description: "`true` si el servidor respondió 2xx"
                  response_status:
                    type: integer
                    description: "HTTP status de la respuesta del webhook (0 si hubo error de conexión)"
                  event_id:
                    type: string
                    format: uuid
                    description: "ID del evento de prueba enviado"

  /teams/{teamID}/webhooks/{webhookID}/deliveries:
    get:
      tags: [Webhooks]
      summary: Historial de entregas de un webhook
      security:
        - BearerAuth: []
      parameters:
        - name: teamID
          in: path
          required: true
          schema:
            type: string
          example: clinica-norte
        - name: webhookID
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: a1b2c3d4-e5f6-7890-abcd-ef1234567890
      responses:
        '200':
          description: Últimas 50 entregas
          content:
            application/json:
              schema:
                type: array
                items:
                  type: object
                  properties:
                    id:
                      type: integer
                    event_type:
                      type: string
                    event_id:
                      type: string
                      format: uuid
                    response_status:
                      type: integer
                    status:
                      type: string
                      enum: [pending, retrying, delivered, exhausted]
                      description: Estado de la entrega
                    attempt:
                      type: integer
                    next_retry_at:
                      type: string
                      format: date-time
                      nullable: true
                      description: Fecha del próximo reintento (null si delivered/exhausted)
                    delivered_at:
                      type: string
                      format: date-time
                    created_at:
                      type: string
                      format: date-time
              example:
                - id: 1
                  event_type: "event.created"
                  event_id: "d4e5f6a7-b8c9-0123-def0-123456789abc"
                  response_status: 200
                  status: "delivered"
                  attempt: 1
                  next_retry_at: null
                  delivered_at: "2026-03-15T10:31:02Z"
                  created_at: "2026-03-15T10:31:00Z"
                - id: 2
                  event_type: "event.cancelled"
                  event_id: "e5f6a7b8-c9d0-1234-ef01-23456789abcd"
                  response_status: 500
                  status: "retrying"
                  attempt: 2
                  next_retry_at: "2026-03-15T11:05:00Z"
                  delivered_at: null
                  created_at: "2026-03-15T10:35:00Z"

  # ─── Audit Log ─────────────────────────────────────────────────────────────
  /teams/{teamID}/audit-log:
    get:
      summary: Consultar audit log del equipo
      description: |
        Muestra todas las operaciones de escritura realizadas por cualquier app
        en este equipo. Útil para auditoría y transparencia.
      tags: [Audit]
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/teamID'
        - name: action
          in: query
          schema:
            type: string
            enum: [create, update, delete]
          description: Filtrar por tipo de acción
        - name: resource
          in: query
          schema:
            type: string
            enum: [events, consumers, webhooks, records, notes, messages, prescriptions]
          description: Filtrar por tipo de recurso
        - name: app_id
          in: query
          schema:
            type: string
            format: uuid
          description: Filtrar por app específica
        - name: from
          in: query
          schema:
            type: string
            format: date-time
          description: Fecha inicio (ISO 8601)
        - name: to
          in: query
          schema:
            type: string
            format: date-time
          description: Fecha fin (ISO 8601)
        - name: limit
          in: query
          schema:
            type: integer
            default: 50
            maximum: 100
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Audit log paginado
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: integer
                        action:
                          type: string
                        resource_type:
                          type: string
                        resource_id:
                          type: string
                          nullable: true
                        method:
                          type: string
                        path:
                          type: string
                        status_code:
                          type: integer
                        duration_ms:
                          type: integer
                        app_name:
                          type: string
                        app_client_id:
                          type: string
                        request_id:
                          type: string
                        created_at:
                          type: string
                          format: date-time
                  pagination:
                    type: object
                    properties:
                      total:
                        type: integer
                      limit:
                        type: integer
                      offset:
                        type: integer
                      has_more:
                        type: boolean
              example:
                data:
                  - id: 42
                    action: "create"
                    resource_type: "events"
                    resource_id: null
                    method: "POST"
                    path: "/public/v1/teams/mi-clinica/events"
                    status_code: 201
                    duration_ms: 245
                    app_name: "Mi App de Turnos"
                    app_client_id: "abc123def456"
                    request_id: "550e8400-e29b-41d4-a716-446655440000"
                    created_at: "2026-03-15T14:30:00Z"
                  - id: 41
                    action: "update"
                    resource_type: "consumers"
                    resource_id: "abc123"
                    method: "POST"
                    path: "/public/v1/teams/mi-clinica/consumers/abc123"
                    status_code: 200
                    duration_ms: 120
                    app_name: "Mi App de Turnos"
                    app_client_id: "abc123def456"
                    request_id: "661f9500-f30c-52e5-b827-557766551111"
                    created_at: "2026-03-15T14:25:00Z"
                pagination:
                  total: 156
                  limit: 50
                  offset: 0
                  has_more: true

  # ─── Prescriptions ──────────────────────────────────────────────────────────
  /teams/{teamID}/prescriptions:
    get:
      summary: Listar recetas del team
      tags: [Prescriptions]
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/teamID'
      responses:
        '200':
          description: Lista de recetas
          content:
            application/json:
              schema:
                type: array
              example:
                - id: "prescriptions/rx-001"
                  resource:
                    id: "resources/dr-garcia"
                    label: "Dr. García"
                  consumer:
                    id: "consumers/xyz789"
                    firstName: "María"
                    lastName: "López"
                  medications:
                    - name: "Losartán 50mg"
                      dosage: "1 comprimido cada 12 horas"
                      duration: "30 días"
                  number: "RX-2026-0001"
                  pdf: "https://api.drapp.com.ar/prescriptions/rx-001.pdf"
                  createdAt: "2026-03-10T14:30:00Z"
    post:
      summary: Crear receta
      tags: [Prescriptions]
      description: |
        Crea una receta para un paciente. El body debe incluir `resource.id`
        (el profesional que prescribe) y `consumer.id` (el paciente).
        El resource debe tener `canCreatePrescriptions: true` en su configuración.
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/teamID'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - resource
                - consumer
                - medications
              properties:
                resource:
                  type: object
                  required: [id]
                  properties:
                    id:
                      type: string
                      description: ID del profesional (ej "dr-garcia")
                consumer:
                  type: object
                  required: [id]
                  properties:
                    id:
                      type: string
                medications:
                  type: array
                  items:
                    type: object
            example:
              resource:
                id: "dr-garcia"
              consumer:
                id: "xyz789"
              medications:
                - name: "Enalapril 10mg"
                  dosage: "1 comprimido por día"
                  duration: "30 días"
                - name: "Aspirina 100mg"
                  dosage: "1 comprimido por día"
                  duration: "30 días"
      responses:
        '200':
          description: Receta creada
          content:
            application/json:
              schema:
                type: object
                properties:
                  pdf:
                    type: string
                    description: URL del PDF de la receta
                  number:
                    type: string
                  token:
                    type: string
              example:
                pdf: "https://api.drapp.com.ar/prescriptions/rx-002.pdf"
                number: "RX-2026-0002"
                token: "eyJhbGciOiJIUzI1NiJ9.abc123"

  /teams/{teamID}/consumers/{consumerID}/prescriptions:
    get:
      summary: Listar recetas de un paciente
      tags: [Prescriptions]
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/teamID'
        - name: consumerID
          in: path
          required: true
          schema:
            type: string
          example: xyz789
      responses:
        '200':
          description: Lista de recetas del paciente
          content:
            application/json:
              schema:
                type: array
              example:
                - id: "prescriptions/rx-001"
                  resource:
                    id: "resources/dr-garcia"
                    label: "Dr. García"
                  medications:
                    - name: "Losartán 50mg"
                      dosage: "1 comprimido cada 12 horas"
                      duration: "30 días"
                  number: "RX-2026-0001"
                  pdf: "https://api.drapp.com.ar/prescriptions/rx-001.pdf"
                  createdAt: "2026-03-10T14:30:00Z"
                - id: "prescriptions/rx-002"
                  resource:
                    id: "resources/dr-garcia"
                    label: "Dr. García"
                  medications:
                    - name: "Enalapril 10mg"
                      dosage: "1 comprimido por día"
                      duration: "30 días"
                    - name: "Aspirina 100mg"
                      dosage: "1 comprimido por día"
                      duration: "30 días"
                  number: "RX-2026-0002"
                  pdf: "https://api.drapp.com.ar/prescriptions/rx-002.pdf"
                  createdAt: "2026-03-15T11:00:00Z"

  # === KEY MANAGEMENT (Auth0 JWT — no requiere API key) ===
  # Estos endpoints usan Auth0 JWT y se montan en /public/install/ (no /public/v1/)

  /install/installations/{id}/regenerate:
    servers:
      - url: https://api.drapp.com.ar/public
        description: Producción
      - url: https://api.qa.drapp.com.ar/public
        description: QA
    post:
      tags: [Key Management]
      summary: Regenerar API key (invalidación inmediata)
      description: |
        Genera una nueva API key para la instalación e invalida la anterior **inmediatamente**.

        Cualquier miembro del equipo puede regenerar la key — no se requiere ser el instalador original.

        ⚠️ La key anterior deja de funcionar en el acto. Si tu integración está en producción,
        usá `/rotate` en su lugar para hacer la rotación sin downtime.

        **Requiere Auth0 JWT** (sesión de usuario en el Developer Portal o App Store),
        no API key de DrApp.
      security:
        - Auth0JWT: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: ID de la instalación
          example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
      responses:
        '200':
          description: Nueva API key generada. Guardala — no se muestra de nuevo.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  app_id:
                    type: string
                    format: uuid
                  team_id:
                    type: string
                  api_key_prefix:
                    type: string
                    description: Primeros 20 caracteres de la nueva key (para identificación)
                    example: drapp_live_a1b2c3d4e5
                  api_key:
                    type: string
                    description: API key completa. Solo se devuelve en este momento — no se almacena.
                    example: drapp_live_a1b2c3d4e5f6789012345678901234567890abcd
                  granted_scopes:
                    type: array
                    items:
                      type: string
                  status:
                    type: string
                    enum: [active]
                  key_rotated_at:
                    type: string
                    format: date-time
              example:
                id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                app_id: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
                team_id: "teams/mi-clinica"
                api_key_prefix: "drapp_live_a1b2c3"
                api_key: "drapp_live_a1b2c3d4e5f6789012345678901234567890abcd"
                granted_scopes: ["scheduling:read", "patients:read"]
                status: "active"
                key_rotated_at: "2026-04-16T10:00:00.000Z"
        '400':
          description: La instalación no está activa
          content:
            application/json:
              example:
                error: not_active
                message: Installation is not active
        '403':
          description: El usuario no es miembro del equipo
          content:
            application/json:
              example:
                error: not_team_member
                message: You are not a member of this team
        '404':
          description: Instalación no encontrada
          content:
            application/json:
              example:
                error: not_found
        '401':
          description: Auth0 JWT no provisto o inválido

  /install/installations/{id}/rotate:
    servers:
      - url: https://api.drapp.com.ar/public
        description: Producción
      - url: https://api.qa.drapp.com.ar/public
        description: QA
    post:
      tags: [Key Management]
      summary: Rotación de API key con grace period (sin downtime)
      description: |
        Genera una nueva API key manteniendo la anterior activa durante un período de gracia
        configurable (1 a 168 horas, default 24h). Permite rotación sin downtime.

        **Flujo recomendado:**
        1. Llamar `/rotate` → recibir nueva key + fecha de expiración de la vieja
        2. Desplegar la nueva key en tu aplicación
        3. Verificar que funciona
        4. La key vieja expira automáticamente al vencer `old_key_expires_at`

        Mientras la key vieja esté en grace period, cada request que la use recibirá
        el header `X-API-Key-Deprecated: true; expires=<ISO8601>` como aviso.

        Solo puede haber una rotación pendiente por instalación. Si ya hay una en curso,
        devuelve `409 rotation_in_progress`.

        Cualquier miembro del equipo puede iniciar una rotación.

        **Requiere Auth0 JWT** (sesión de usuario), no API key de DrApp.
      security:
        - Auth0JWT: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: ID de la instalación
          example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                grace_hours:
                  type: integer
                  minimum: 1
                  maximum: 168
                  default: 24
                  description: Horas que la key anterior permanece activa. Mínimo 1h, máximo 7 días (168h).
                  example: 24
            example:
              grace_hours: 24
      responses:
        '201':
          description: Rotación iniciada. Guardá la nueva key — no se muestra de nuevo.
          headers:
            X-API-Key-Deprecated:
              description: |
                Presente en requests que usen la key anterior durante el grace period.
                Formato: `true; expires=<ISO8601>`
              schema:
                type: string
                example: "true; expires=2026-04-17T10:00:00.000Z"
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_key:
                    type: string
                    description: Nueva API key completa. Solo se devuelve en este momento.
                    example: drapp_live_newkey1234567890abcdef1234567890abcd
                  old_key_prefix:
                    type: string
                    description: Prefijo de la key anterior (para identificación)
                    example: drapp_live_a1b2c3
                  old_key_expires_at:
                    type: string
                    format: date-time
                    description: Fecha y hora exacta en que la key anterior dejará de funcionar
                    example: "2026-04-17T10:00:00.000Z"
                  grace_hours:
                    type: integer
                    example: 24
                  message:
                    type: string
                    example: "New key generated. The old key (drapp_live_a1b2c3...) will remain active until 2026-04-17T10:00:00.000Z."
              example:
                api_key: "drapp_live_newkey1234567890abcdef1234567890abcd"
                old_key_prefix: "drapp_live_a1b2c3"
                old_key_expires_at: "2026-04-17T10:00:00.000Z"
                grace_hours: 24
                message: "New key generated. The old key (drapp_live_a1b2c3...) will remain active until 2026-04-17T10:00:00.000Z."
        '400':
          description: La instalación no está activa
          content:
            application/json:
              example:
                error: not_active
        '403':
          description: El usuario no es miembro del equipo
          content:
            application/json:
              example:
                error: not_team_member
        '404':
          description: Instalación no encontrada
          content:
            application/json:
              example:
                error: not_found
        '409':
          description: Ya hay una rotación pendiente para esta instalación
          content:
            application/json:
              example:
                error: rotation_in_progress
                message: There is already a pending key rotation for this installation. Wait for it to complete or cancel it.
        '401':
          description: Auth0 JWT no provisto o inválido

  /install/installations/{id}/refresh-resource:
    servers:
      - url: https://api.drapp.com.ar/public
        description: Producción
      - url: https://api.qa.drapp.com.ar/public
        description: QA
    post:
      tags: [Key Management]
      summary: Actualizar identidad profesional de la instalación
      description: |
        Permite que un usuario que instaló una app sin tener perfil de profesional
        actualice su instalación para obtener los scopes profesionales (`clinical:write`,
        `prescriptions:write`) una vez que su perfil de profesional fue creado.

        Solo puede llamarlo el instalador original (`installed_by`) o un staff.

        Si la app no solicita scopes profesionales, no tiene efecto.
      security:
        - Auth0JWT: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: ID de la instalación
      responses:
        '200':
          description: Instalación actualizada con los scopes profesionales
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  granted_scopes:
                    type: array
                    items:
                      type: string
                    description: Scopes actualizados (ahora incluye clinical:write / prescriptions:write si aplica)
                  installed_by_resource_id:
                    type: string
                    nullable: true
                    description: ID del profesional ahora vinculado a la instalación
        '403':
          description: Solo el instalador original o staff pueden actualizar la identidad
          content:
            application/json:
              example:
                error: forbidden
        '404':
          description: Instalación no encontrada o usuario sin perfil de profesional en el equipo
          content:
            application/json:
              example:
                error: not_found
        '401':
          description: Auth0 JWT no provisto o inválido

  /install/exchange:
    servers:
      - url: https://api.drapp.com.ar/public
        description: Producción
      - url: https://api.qa.drapp.com.ar/public
        description: QA
    post:
      tags: [Key Management]
      summary: Canjear install_token por API key (Token Exchange)
      description: |
        Endpoint server-to-server (sin auth) que permite obtener la API key generada
        durante la instalación sin que el usuario tenga que copiarla manualmente.

        **Flujo completo (Option B):**
        1. El developer registra `post_install_url` en su app
        2. El usuario instala la app desde el App Store
        3. DrApp redirige al usuario a `post_install_url?install_token=<TOKEN>&client_id=<CLIENT_ID>&team_id=<TEAM_ID>`
        4. El backend del developer llama a este endpoint con esos parámetros + `client_secret`
        5. Recibe la `api_key` directamente — el usuario nunca tuvo que copiar nada

        **Propiedades del token:**
        - Formato: 64 caracteres hexadecimales en minúscula
        - TTL: 30 minutos desde la instalación
        - Uso único: se consume con GETDEL atómico (no se puede reutilizar)
        - Si Redis no estaba disponible al instalar, el token no fue generado → el usuario recibe la key en pantalla como fallback

        **No requiere autenticación** — la seguridad se basa en:
        - El `install_token` es efímero y de un solo uso
        - El `client_secret` de la app (verificado con timing-safe comparison)
        - El `client_id` debe coincidir con el token generado para esa app
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [install_token, client_id, client_secret]
              properties:
                install_token:
                  type: string
                  description: Token recibido en la URL de redirect post-instalación
                  pattern: "^[0-9a-f]{64}$"
                  example: "a3f1c2e4b5d6789012345678901234567890abcdef1234567890abcdef123456"
                client_id:
                  type: string
                  description: Client ID de tu app (recibido al registrar la app)
                  example: "drapp_abc123def456"
                client_secret:
                  type: string
                  description: Client secret de tu app (visible en el Developer Portal)
                  example: "cs_abc123..."
            example:
              install_token: "a3f1c2e4b5d6789012345678901234567890abcdef1234567890abcdef123456"
              client_id: "drapp_abc123def456"
              client_secret: "cs_abc123..."
      responses:
        '200':
          description: API key obtenida exitosamente
          content:
            application/json:
              schema:
                type: object
                properties:
                  api_key:
                    type: string
                    description: API key de producción (drapp_live_*). Guardala de forma segura — no se puede recuperar después.
                    example: "drapp_live_a1b2c3d4e5f6789012345678901234567890"
                  installation_id:
                    type: string
                    format: uuid
                    example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                  team_id:
                    type: string
                    description: ID del equipo que instaló la app
                    example: "teams/mi-clinica"
                  granted_scopes:
                    type: array
                    items:
                      type: string
                    description: Scopes efectivamente otorgados (puede ser subconjunto si el installer no es profesional)
                    example: ["scheduling:read", "patients:read"]
        '400':
          description: Faltan campos requeridos
          content:
            application/json:
              example:
                error: missing_fields
                message: "install_token, client_id and client_secret are required"
        '401':
          description: client_secret inválido
          content:
            application/json:
              example:
                error: invalid_client_secret
                message: "Invalid client_secret"
        '403':
          description: client_id no coincide con el token
          content:
            application/json:
              example:
                error: client_id_mismatch
                message: "client_id does not match the install token"
        '404':
          description: Token no encontrado, ya usado, o expirado (también se retorna si el formato del token es inválido)
          content:
            application/json:
              example:
                error: token_not_found
                message: "Install token not found or already used. Tokens expire after 30 minutes."
        '410':
          description: La instalación fue revocada entre la instalación y el exchange
          content:
            application/json:
              example:
                error: installation_revoked
                message: "The installation was revoked before the token could be exchanged"
