# OpenAPI 3.0.3 a propósito: soporta `nullable` nativo y es el target con más
# soporte en generadores de código, servidores MCP y Actions de OpenAI hoy.
openapi: 3.0.3

info:
  title: Protocolo Inmo — API
  version: 0.1.0
  description: |
    Protocolo abierto para publicar y consultar inmuebles, alineado a RESO.


    Contrato **fuente de verdad** del protocolo. De este archivo se derivan la
    API REST, el servidor MCP y el manifiesto del plugin — nada se escribe dos
    veces.

    Dos superficies de lectura sobre el mismo modelo:
    - **REST/JSON** (`/properties`, camelCase, agent-friendly) — superficie
      primaria.
    - **RESO Web API** (`/Property`, subconjunto OData, campos del RESO Data
      Dictionary en PascalCase) — puente para quien ya integra con RESO.

    Principio de apertura: la información básica de una propiedad se lee **sin
    login**. Las API keys son gratuitas y sirven para rate limiting y
    trazabilidad, no para levantar un muro. Diseño completo en
    `docs/protocol/`.
  # license: pendiente de decisión — ver docs/protocol/05-gobernanza.md
  contact:
    name: Protocolo Inmo

servers:
  - url: https://api.{host}/v1
    description: Reemplaza {host} por tu dominio.
    variables:
      host:
        default: inmo.example

tags:
  - name: properties
    description: Búsqueda y lectura de propiedades (superficie REST primaria).
  - name: reso
    description: Compatibilidad RESO Web API (subconjunto OData).
  - name: stats
    description: Agregados de mercado.
  - name: sources
    description: Fuentes de datos y su salud.
  - name: ingest
    description: Carga y actualización de inventario (partners/adaptadores).
  - name: identity
    description: Anunciantes y agencias (RESO Member / Office).
  - name: crm
    description: Panel del anunciante — gestionar las propiedades propias.

security:
  - {}                      # lectura básica: sin autenticación
  - ApiKeyAuth: []          # lectura ampliada: key gratuita

paths:
  /properties:
    get:
      tags: [properties]
      operationId: searchProperties
      summary: Buscar propiedades
      description: |
        Filtros por transacción, tipo, precio, recámaras y **radio geográfico**
        (`near` + `radius`, sobre índice PostGIS). Sin login para lo básico.
      parameters:
        - $ref: '#/components/parameters/TransactionType'
        - $ref: '#/components/parameters/PropertyType'
        - $ref: '#/components/parameters/MinPrice'
        - $ref: '#/components/parameters/MaxPrice'
        - $ref: '#/components/parameters/MinBedrooms'
        - $ref: '#/components/parameters/Near'
        - $ref: '#/components/parameters/Radius'
        - $ref: '#/components/parameters/City'
        - $ref: '#/components/parameters/PostalCode'
        - $ref: '#/components/parameters/Status'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Lista paginada de propiedades (representación compacta).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PropertyList' }
              example:
                count: 17
                results:
                  - listingKey: 'fb:1664330911533509'
                    transactionType: ForLease
                    propertyType: Residential
                    listPrice: 8000
                    listPriceCurrency: MXN
                    priceTrusted: true
                    bedroomsTotal: 3
                    bathroomsTotalInteger: 1
                    city: Toluca de Lerdo
                    postalCode: '50060'
                    latitude: 19.2979
                    longitude: -99.6419
                    photoCount: 7
                    summary: Casa de 3 recámaras en renta, $8,000/mes, Toluca de Lerdo.
                    provenance: { source: facebook-marketplace, confidence: 0.82 }
                    links:
                      self: /v1/properties/fb:1664330911533509
                      source: https://www.facebook.com/marketplace/item/1664330911533509/
                next: 'cursor:eyJwIjo4MDAwfQ'
        '400': { $ref: '#/components/responses/BadRequest' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /properties/{listingKey}:
    parameters:
      - $ref: '#/components/parameters/ListingKey'
    get:
      tags: [properties]
      operationId: getProperty
      summary: Ficha completa de una propiedad
      responses:
        '200':
          description: La propiedad, con media y procedencia.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Property' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      tags: [ingest]
      operationId: upsertProperty
      summary: Crear o actualizar una propiedad
      description: |
        Requiere key con scope `write`. **No publica directo**: aterriza en
        staging y pasa por las puertas de validación antes de aparecer en las
        búsquedas.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PropertyInput' }
      responses:
        '202':
          description: Aceptada en staging; pendiente de validación.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestReceipt' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /properties/{listingKey}/media:
    parameters:
      - $ref: '#/components/parameters/ListingKey'
    get:
      tags: [properties]
      operationId: getPropertyMedia
      summary: Fotos y video de una propiedad
      responses:
        '200':
          description: Media de la propiedad.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Media' }
        '404': { $ref: '#/components/responses/NotFound' }

  /stats:
    get:
      tags: [stats]
      operationId: getStats
      summary: Agregados de mercado de una zona
      description: Mediana de precio, inventario por tipo y días en mercado.
      parameters:
        - $ref: '#/components/parameters/Near'
        - $ref: '#/components/parameters/Radius'
        - $ref: '#/components/parameters/City'
      responses:
        '200':
          description: Agregados de la zona.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Stats' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /sources:
    get:
      tags: [sources]
      operationId: listSources
      summary: Fuentes de datos activas y su salud
      responses:
        '200':
          description: Fuentes registradas.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Source' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /members:
    get:
      tags: [identity]
      operationId: listMembers
      summary: Directorio de anunciantes (RESO Member)
      parameters:
        - name: officeKey
          in: query
          description: Filtra por agencia.
          schema: { type: string }
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Miembros.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Member' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /members/{memberKey}:
    parameters:
      - name: memberKey
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [identity]
      operationId: getMember
      summary: Un anunciante
      responses:
        '200':
          description: El miembro.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Member' }
        '404': { $ref: '#/components/responses/NotFound' }

  /offices:
    get:
      tags: [identity]
      operationId: listOffices
      summary: Directorio de agencias (RESO Office)
      responses:
        '200':
          description: Agencias.
          content:
            application/json:
              schema:
                type: array
                items: { $ref: '#/components/schemas/Office' }
        '429': { $ref: '#/components/responses/RateLimited' }

  /offices/{officeKey}:
    parameters:
      - name: officeKey
        in: path
        required: true
        schema: { type: string }
    get:
      tags: [identity]
      operationId: getOffice
      summary: Una agencia
      responses:
        '200':
          description: La agencia.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Office' }
        '404': { $ref: '#/components/responses/NotFound' }

  /me:
    get:
      tags: [crm]
      operationId: getMe
      summary: El usuario autenticado y su contexto (CRM)
      description: Miembro, agencia y roles del principal. Requiere OAuth2.
      security:
        - OAuth2: [read]
      responses:
        '200':
          description: Contexto del usuario.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Me' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /me/listings:
    get:
      tags: [crm]
      operationId: getMyListings
      summary: Mis propiedades (las que administro)
      description: |
        Un agente ve las suyas; un broker/admin ve las de toda su agencia. El
        alcance lo determina el rol, no un parámetro.
      security:
        - OAuth2: [read]
      parameters:
        - $ref: '#/components/parameters/Status'
        - $ref: '#/components/parameters/Cursor'
        - $ref: '#/components/parameters/Limit'
      responses:
        '200':
          description: Propiedades administradas.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PropertyList' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      tags: [crm]
      operationId: createMyListing
      summary: Publicar una propiedad propia (CRM)
      description: |
        Alta desde el CRM por un humano. Queda con `listAgentKey` = el miembro
        autenticado y `listOfficeKey` = su agencia. Pasa por las mismas puertas
        de validación que la ingesta de partners.
      security:
        - OAuth2: ['write:listings']
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PropertyInput' }
      responses:
        '202':
          description: Aceptada en staging.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestReceipt' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /ingest:
    post:
      tags: [ingest]
      operationId: ingestProperties
      summary: Cargar un lote de propiedades (partners/adaptadores)
      description: |
        Requiere key con scope `write`. Cada propiedad pasa por staging y
        validación; el recibo indica cuántas se aceptaron y por qué se
        rechazaron las demás.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: array
              items: { $ref: '#/components/schemas/PropertyInput' }
              maxItems: 1000
      responses:
        '202':
          description: Lote recibido en staging.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/IngestReceipt' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /Property:
    get:
      tags: [reso]
      operationId: queryPropertyReso
      summary: Consulta RESO Web API (subconjunto OData)
      description: |
        Puente para integradores que ya hablan RESO. Devuelve el mismo dato con
        los nombres del **RESO Data Dictionary** (PascalCase) y un sobre OData.
        Soporta un subconjunto de OData: `$filter`, `$select`, `$top`, `$skip`,
        `$orderby`, `$count`.
      parameters:
        - name: $filter
          in: query
          schema: { type: string }
          example: ListPrice le 9000 and BedroomsTotal ge 2
        - name: $select
          in: query
          schema: { type: string }
          example: ListingKey,ListPrice,BedroomsTotal,City
        - name: $top
          in: query
          schema: { type: integer, default: 25, maximum: 200 }
        - name: $skip
          in: query
          schema: { type: integer, default: 0 }
        - name: $orderby
          in: query
          schema: { type: string }
          example: ListPrice asc
        - name: $count
          in: query
          schema: { type: boolean, default: false }
      responses:
        '200':
          description: Sobre OData con propiedades en nombres RESO.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ODataPropertyList' }
        '400': { $ref: '#/components/responses/BadRequest' }

components:

  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Key gratuita de auto-servicio. Opcional para lectura básica.
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        Token OAuth2 (portador) para escritura e identidad. La RESO Web API
        usa OAuth2; el CRM y los partners obtienen su token por este flujo.
    OAuth2:
      type: oauth2
      description: |
        Flujo para humanos del CRM (agentes de una agencia o individuales) y
        para partners. Los scopes acotan qué puede hacer cada principal.
      flows:
        authorizationCode:
          authorizationUrl: https://auth.inmo.example/oauth/authorize
          tokenUrl: https://auth.inmo.example/oauth/token
          scopes:
            read: Lectura del inventario.
            'write:listings': Crear y editar propiedades PROPIAS.
            'manage:office': Gestionar la agencia y sus miembros (rol broker/admin).

  parameters:
    ListingKey:
      name: listingKey
      in: path
      required: true
      description: ID estable de la propiedad (RESO ListingKey).
      schema: { type: string }
      example: 'fb:1664330911533509'
    TransactionType:
      name: transactionType
      in: query
      schema: { $ref: '#/components/schemas/TransactionType' }
    PropertyType:
      name: propertyType
      in: query
      schema: { $ref: '#/components/schemas/PropertyType' }
    MinPrice:
      name: minPrice
      in: query
      description: Precio mínimo, en la moneda del filtro.
      schema: { type: number, minimum: 0 }
    MaxPrice:
      name: maxPrice
      in: query
      schema: { type: number, minimum: 0 }
    MinBedrooms:
      name: minBedrooms
      in: query
      schema: { type: integer, minimum: 0 }
    Near:
      name: near
      in: query
      description: 'Centro del radio de búsqueda, `lat,lng`.'
      schema: { type: string, pattern: '^-?\d+(\.\d+)?,-?\d+(\.\d+)?$' }
      example: '19.2926,-99.6569'
    Radius:
      name: radius
      in: query
      description: Radio en metros (requiere `near`).
      schema: { type: integer, minimum: 1, maximum: 200000, default: 10000 }
    City:
      name: city
      in: query
      schema: { type: string }
    PostalCode:
      name: postalCode
      in: query
      schema: { type: string }
    Status:
      name: status
      in: query
      schema: { $ref: '#/components/schemas/StandardStatus' }
    Cursor:
      name: cursor
      in: query
      description: Cursor de paginación devuelto en `next`.
      schema: { type: string }
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }

  responses:
    BadRequest:
      description: Parámetros inválidos.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unauthorized:
      description: Falta o es inválida la key de escritura.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: No existe una propiedad con ese listingKey.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    RateLimited:
      description: Se excedió el límite de peticiones.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:

    # ── enums (lookups del RESO Data Dictionary 2.0) ───────────────────────
    StandardStatus:
      type: string
      description: |
        RESO StandardStatus — lookup oficial. Se adopta el conjunto completo aunque
        el adaptador de Facebook sólo emita Active/Pending/Closed/Withdrawn.
      enum:
        - Active
        - Active Under Contract
        - Pending
        - Closed
        - Coming Soon
        - Hold
        - Withdrawn
        - Canceled
        - Expired
        - Incomplete
        - Delete
    PropertyType:
      type: string
      description: |
        RESO PropertyType — lookup oficial. En RESO la transacción (venta/renta)
        ya está codificada en el tipo (p. ej. `Residential Lease`). `transactionType`
        es una conveniencia derivada, no reemplaza a este campo.
      enum:
        - Residential
        - Residential Lease
        - Residential Income
        - Commercial Sale
        - Commercial Lease
        - Business Opportunity
        - Land
        - Farm
        - Manufactured In Park
    PropertySubType:
      type: string
      description: RESO PropertySubType — lookup oficial (subconjunto de uso común en MX).
      enum:
        - Single Family Residence
        - Condominium
        - Townhouse
        - Apartment
        - Duplex
        - Multi Family
        - Manufactured Home
        - Mobile Home
        - Cabin
        - Ranch
        - Unimproved Land
        - Office
        - Retail
        - Warehouse
        - Other
    TransactionType:
      type: string
      description: |
        Conveniencia derivada de PropertyType para la superficie REST/agentes.
        No es un campo RESO; RESO lo expresa dentro de PropertyType.
      enum: [ForSale, ForLease]
    LivingAreaUnits:
      type: string
      description: RESO LivingAreaUnits. Obligatorio si hay área; nunca asumir.
      enum: [SquareMeters, SquareFeet]

    # ── procedencia (extensión X-Provenance) ───────────────────────────────
    Provenance:
      type: object
      description: De dónde vino el dato y con qué confianza. Campo de primera clase.
      required: [source, confidence]
      properties:
        source:
          type: string
          description: Adaptador/fuente de origen (RESO OriginatingSystemName).
          example: facebook-marketplace
        sourceUrl:
          type: string
          format: uri
        fetchedAt:
          type: string
          format: date-time
        confidence:
          type: number
          minimum: 0
          maximum: 1
          description: Resultado agregado de las puertas de validación.
        validations:
          type: array
          items: { type: string }
          example: [schema-ok, geocode-ok, not-demand, not-commercial]

    Links:
      type: object
      properties:
        self: { type: string, description: Ficha en el protocolo. }
        source: { type: string, description: Anuncio en la fuente original. }

    Media:
      type: object
      required: [url]
      properties:
        url: { type: string, format: uri }
        width: { type: integer }
        height: { type: integer }
        caption:
          type: string
          description: Texto alternativo / OCR cuando la fuente lo provee.
        order: { type: integer }

    # ── lista paginada (resultados de búsqueda) ────────────────────────────
    PropertyList:
      type: object
      required: [count, results]
      properties:
        count:
          type: integer
          description: Total de coincidencias (best-effort).
        results:
          type: array
          items: { $ref: '#/components/schemas/PropertySummary' }
        next:
          type: string
          nullable: true
          description: Cursor para la página siguiente; null si no hay más.

    # ── property: representación compacta (resultados de búsqueda) ──────────
    PropertySummary:
      type: object
      required: [listingKey, transactionType, listPrice, listPriceCurrency]
      properties:
        listingKey: { type: string, example: 'fb:1664330911533509' }
        transactionType: { $ref: '#/components/schemas/TransactionType' }
        propertyType: { $ref: '#/components/schemas/PropertyType' }
        listPrice: { type: number, example: 8000 }
        listPriceCurrency: { type: string, example: MXN }
        priceTrusted: { type: boolean }
        bedroomsTotal: { type: integer, nullable: true }
        bathroomsTotalInteger: { type: integer, nullable: true }
        city: { type: string, nullable: true }
        postalCode: { type: string, nullable: true }
        latitude: { type: number, nullable: true }
        longitude: { type: number, nullable: true }
        photoCount: { type: integer }
        photoUrl:
          type: string
          nullable: true
          description: 'Foto principal (miniatura). Nota: las URLs de la fuente pueden caducar.'
        cachedPhotos:
          type: integer
          description: Cuántas fotos propias servimos (para el carrusel).
        hostName:
          type: string
          nullable: true
          description: Nombre del anunciante/anfitrión.
        hostType:
          type: string
          nullable: true
          description: 'Agencia o "Anfitrión particular".'
        publisherKey:
          type: string
          nullable: true
          description: Clave del anunciante (teléfono normalizado) para agrupar sus publicaciones.
        hasContact:
          type: boolean
          description: Si la publicación trae teléfono de contacto visible.
        summary:
          type: string
          description: Resumen en lenguaje natural, para agentes.
        provenance:
          type: object
          properties:
            source: { type: string }
            confidence: { type: number }
        links: { $ref: '#/components/schemas/Links' }

    # ── property: ficha completa ───────────────────────────────────────────
    Property:
      type: object
      required: [listingKey, transactionType, propertyType, listPrice, listPriceCurrency]
      properties:
        listingKey:
          type: string
          description: RESO ListingKey. ID estable con prefijo de fuente.
          example: 'fb:1664330911533509'
        listingId:
          type: string
          description: RESO ListingId (ID de la fuente).
        standardStatus: { $ref: '#/components/schemas/StandardStatus' }
        propertyType: { $ref: '#/components/schemas/PropertyType' }
        propertySubType: { $ref: '#/components/schemas/PropertySubType' }
        transactionType: { $ref: '#/components/schemas/TransactionType' }
        listPrice:
          type: number
          description: RESO ListPrice, en la unidad mayor de la moneda.
        listPriceCurrency:
          type: string
          description: RESO ListPriceCurrency (ISO-4217). Obligatorio.
          example: MXN
        priceTrusted:
          type: boolean
          description: X-PriceTrusted. Falso si la moneda del precio es ambigua.
        bedroomsTotal: { type: integer, nullable: true }
        bathroomsTotalInteger: { type: integer, nullable: true }
        livingArea: { type: number, nullable: true }
        livingAreaUnits: { $ref: '#/components/schemas/LivingAreaUnits' }
        publicRemarks:
          type: string
          nullable: true
          description: RESO PublicRemarks (descripción).
        latitude: { type: number, nullable: true }
        longitude: { type: number, nullable: true }
        unparsedAddress:
          type: string
          nullable: true
          description: RESO UnparsedAddress (dirección en texto libre).
        postalCode: { type: string, nullable: true }
        city: { type: string, nullable: true }
        stateOrProvince: { type: string, nullable: true }
        country: { type: string, default: MX }
        colonia:
          type: string
          nullable: true
          description: Extensión X-Colonia.
        municipio:
          type: string
          nullable: true
          description: Extensión X-Municipio.
        media:
          type: array
          items: { $ref: '#/components/schemas/Media' }
        photoCount: { type: integer }
        originatingSystemName:
          type: string
          description: RESO OriginatingSystemName (la fuente/sistema de origen).
        listAgentKey:
          type: string
          nullable: true
          description: RESO ListAgentKey — Member que publica (individuo o agente).
        listOfficeKey:
          type: string
          nullable: true
          description: RESO ListOfficeKey — Office (agencia) del anuncio, si aplica.
        contactPhone:
          type: string
          nullable: true
          description: Teléfono que el anunciante publicó visible en su ficha (10 dígitos MX).
        contactWhatsapp:
          type: string
          nullable: true
          description: Enlace wa.me al WhatsApp del anunciante, si hay teléfono.
        publisherKey:
          type: string
          nullable: true
          description: Clave del anunciante (teléfono) — enlaza a su perfil y demás publicaciones.
        cachedPhotos:
          type: integer
          description: Cuántas fotos propias servimos.
        host:
          nullable: true
          description: Anunciante/anfitrión de la propiedad.
          type: object
          properties:
            key: { type: string }
            name: { type: string }
            type: { type: string, description: 'Profesional | Particular' }
            officeName: { type: string, nullable: true }
        provenance: { $ref: '#/components/schemas/Provenance' }
        summary:
          type: string
          description: Resumen en lenguaje natural, para agentes.
        links: { $ref: '#/components/schemas/Links' }
        priceHistory:
          type: array
          description: Extensión — RESO no lo estandariza.
          items: { $ref: '#/components/schemas/PricePoint' }
        modificationTimestamp: { type: string, format: date-time }
        firstSeenAt: { type: string, format: date-time }
        lastSeenAt: { type: string, format: date-time }

    PricePoint:
      type: object
      properties:
        price: { type: number }
        currency: { type: string }
        observedAt: { type: string, format: date-time }

    # ── identidad: RESO Member / Office (individuos y agencias) ─────────────
    MemberType:
      type: string
      description: RESO-alineado. `Agent` individual; `Broker` gestiona la oficina.
      enum: [Agent, Broker, Assistant]
    OfficeType:
      type: string
      enum: [Brokerage, Agency, Individual]

    Member:
      type: object
      description: |
        Un anunciante (RESO Member). Puede ser **individual** (sin oficina, o su
        propia oficina de una persona) o pertenecer a una **agencia** (Office).
      required: [memberKey, memberType]
      properties:
        memberKey: { type: string, description: RESO MemberKey (ID estable). }
        memberMlsId: { type: string, nullable: true, description: RESO MemberMlsId. }
        memberType: { $ref: '#/components/schemas/MemberType' }
        memberFirstName: { type: string }
        memberLastName: { type: string }
        memberFullName: { type: string }
        memberEmail: { type: string, format: email, nullable: true }
        officeKey:
          type: string
          nullable: true
          description: RESO OfficeKey. Null si es individual sin agencia.
        memberStatus:
          type: string
          enum: [Active, Inactive]
        listingCount: { type: integer }

    Office:
      type: object
      description: Una agencia/brokerage (RESO Office).
      required: [officeKey, officeName]
      properties:
        officeKey: { type: string, description: RESO OfficeKey. }
        officeMlsId: { type: string, nullable: true }
        officeName: { type: string }
        officeType: { $ref: '#/components/schemas/OfficeType' }
        officePhone: { type: string, nullable: true }
        officeEmail: { type: string, format: email, nullable: true }
        memberCount: { type: integer }
        listingCount: { type: integer }

    Me:
      type: object
      description: El principal autenticado (usuario del CRM) y su contexto.
      properties:
        userId: { type: string }
        email: { type: string, format: email }
        member: { $ref: '#/components/schemas/Member' }
        office:
          description: La agencia del miembro; ausente si es individual.
          allOf: [{ $ref: '#/components/schemas/Office' }]
        roles:
          type: array
          description: Roles efectivos en la plataforma / la oficina.
          items:
            type: string
            enum: [individual, agent, broker, admin]
        scopes:
          type: array
          items: { type: string }
          example: [read, 'write:listings']

    # ── property: entrada de ingesta ───────────────────────────────────────
    PropertyInput:
      type: object
      description: |
        Lo que un partner/adaptador envía. `listingKey` es opcional en creación
        (el protocolo lo deriva). La procedencia se completa del lado del
        servidor con la fuente autenticada.
      required: [transactionType, propertyType, listPrice, listPriceCurrency]
      properties:
        listingId: { type: string }
        transactionType: { $ref: '#/components/schemas/TransactionType' }
        propertyType: { $ref: '#/components/schemas/PropertyType' }
        propertySubType: { $ref: '#/components/schemas/PropertySubType' }
        listPrice: { type: number }
        listPriceCurrency: { type: string, example: MXN }
        bedroomsTotal: { type: integer }
        bathroomsTotalInteger: { type: integer }
        livingArea: { type: number }
        livingAreaUnits: { $ref: '#/components/schemas/LivingAreaUnits' }
        publicRemarks: { type: string }
        latitude: { type: number }
        longitude: { type: number }
        unparsedAddress: { type: string }
        postalCode: { type: string }
        city: { type: string }
        stateOrProvince: { type: string }
        media:
          type: array
          items: { $ref: '#/components/schemas/Media' }
        sourceUrl: { type: string, format: uri }

    IngestReceipt:
      type: object
      properties:
        received: { type: integer }
        acceptedToStaging: { type: integer }
        rejected: { type: integer }
        rejections:
          type: array
          items:
            type: object
            properties:
              index: { type: integer }
              listingId: { type: string }
              gate:
                type: string
                description: Puerta de validación que rechazó.
                example: geocode
              reason: { type: string }

    Stats:
      type: object
      properties:
        scope:
          type: object
          properties:
            near: { type: string }
            radius: { type: integer }
            city: { type: string }
        sale:
          $ref: '#/components/schemas/StatBucket'
        lease:
          $ref: '#/components/schemas/StatBucket'
        medianDaysOnMarket: { type: number, nullable: true }
    StatBucket:
      type: object
      properties:
        count: { type: integer }
        medianPrice: { type: number, nullable: true }
        currency: { type: string }

    Source:
      type: object
      properties:
        name: { type: string, example: facebook-marketplace }
        active: { type: boolean }
        experimental: { type: boolean }
        propertyCount: { type: integer }
        health:
          type: object
          properties:
            status:
              type: string
              enum: [ok, degraded, down]
            lastRunAt: { type: string, format: date-time }
            rejectRate:
              type: number
              description: Fracción rechazada por las puertas de validación.

    # ── sobre OData / RESO ─────────────────────────────────────────────────
    ODataPropertyList:
      type: object
      properties:
        '@odata.count': { type: integer }
        value:
          type: array
          items: { $ref: '#/components/schemas/PropertyRESO' }
    PropertyRESO:
      type: object
      description: |
        Misma propiedad con nombres del RESO Data Dictionary (PascalCase).
        Subconjunto — se amplía según se necesite.
      properties:
        ListingKey: { type: string }
        ListingId: { type: string }
        StandardStatus: { type: string }
        PropertyType: { type: string }
        PropertySubType: { type: string }
        ListPrice: { type: number }
        ListPriceCurrency: { type: string }
        BedroomsTotal: { type: integer }
        BathroomsTotalInteger: { type: integer }
        LivingArea: { type: number }
        LivingAreaUnits: { type: string }
        PublicRemarks: { type: string }
        Latitude: { type: number }
        Longitude: { type: number }
        PostalCode: { type: string }
        City: { type: string }
        StateOrProvince: { type: string }
        Country: { type: string }
        UnparsedAddress: { type: string }
        OriginatingSystemName: { type: string }
        ModificationTimestamp: { type: string, format: date-time }

    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              example: invalid_parameter
            message:
              type: string
              example: 'radius requiere near'
            details:
              type: object
              additionalProperties: true
