openapi: 3.0.0
info:
  title: Bundleport Hotel Content API
  description: |
    REST API for **static and semi-static hotel catalog** data: hotels, destinations, rooms, boards,
    categories, metadata, and scope audit/stats. Public paths are under `/content/hotels/v1/...`.

    **Related docs:** [Static vs transactional data](/overview/concepts#static-data-vs-transactional-data),
    [Booking API](/api/services-aggregator/bundleport-hotels-api).

    **Live availability and booking** use the Hotels (aggregator) API, not this service.

    **Health:** `GET /content/hotels/v1/health` (no auth). Other routes require authentication via API key / bearer token as configured for your environment.

    **Provider codes and families.** A few providers answer to more than one code, because content
    and mappings were onboarded under different names. Send the code you were given. The API
    resolves aliases to the same family before querying. Responses report the code as stored, so a
    request using one alias can come back labelled with the other in mapping-related fields.

    **Try it:** Default servers are production and test gateways; paths already include `/content/hotels/v1/...`. Use `sk_test_*` with the test host.
  version: 1.0.0
  contact:
    name: Bundleport Team

servers:
  - url: https://api.bundleport.com
    description: Production server
  - url: https://test-api.bundleport.com
    description: Test server
  - url: http://localhost:8001
    description: Local development server

tags:
  - name: System
    description: System health and status endpoints
  - name: Hotels - Content
    description: Hotel content and catalog endpoints
  - name: Content - Statistics
    description: Content statistics and audit endpoints
  - name: Places
    description: Destination and place search endpoints

paths:
  /content/hotels/v1/hotels:
    post:
      summary: Get list of hotels
      description: |
        Retrieves a paginated list of hotels based on search query.

        **Hotel codes:** `code` and `providerHotelCode` are both the provider-native code
        (used for availability/booking). The Bundleport base code is in
        `externalIds.bundleportBaseCode` (with `providerPropertyIds` when available).
        Use `outputCodeType: provider` to omit `externalIds`.

        **Note:** `returnHotelCodes` and the `hotelCodes.base[]/provider[]` arrays belong to
        POST /hotels/filter, not here. A sandbox connection (e.g. provider `demo`) has no
        catalog and falls back to the full shared multi-provider catalog (with a warning).
      operationId: getHotels
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HotelsRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HotelsResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/hotels/by-base-codes:
    post:
      summary: Get hotels by base codes
      description: |
        Returns hotel content for a list of Bundleport base codes (mapping_hotels.base_code).
        Use preferredContentSource to choose primary content when multiple providers have the same hotel;
        use includeAllSources to get content from all providers per base code.
      operationId: getHotelsByBaseCodes
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HotelsByBaseCodesRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HotelsByBaseCodesResponse'
        '400':
          description: Bad request (e.g. missing or empty baseCodes)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/hotels/filter:
    post:
      summary: Filter hotel codes
      description: |
        Returns hotel codes matching filter criteria. Optimized for pre-filtering before availability searches.
        
        **Features:**
        - Multi-category filtering (e.g., 3-5 star hotels)
        - Board codes filtering (e.g., hotels with all-inclusive)
        - Geo-proximity filtering (nearby hotels within radius)
        - Hotel name search
        - Keyset pagination with cursor
        - Returns both base and provider hotel codes
        
        **Use Cases:**
        - Agency Panel: Filter hotels by place + facets before search
        - Aggregator: Pre-filter hotel set before availability call
      operationId: filterHotelCodes
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HotelFilterRequest'
            examples:
              byCity:
                summary: Filter by canonical city
                value:
                  providerCode: "example"
                  filters:
                    canonicalCityId: "city_123456"
                    categories: ["3STAR", "4STAR", "5STAR"]
                  pageSize: 100
              byBoardCodes:
                summary: Filter by board codes
                value:
                  providerCode: "example"
                  filters:
                    canonicalCityId: "city_123456"
                    boardCodes: ["AI", "HB", "BB"]
                  returnHotelCodes: ["base", "provider"]
              nearby:
                summary: Filter nearby hotels
                value:
                  providerCode: "example"
                  filters:
                    nearby:
                      latitude: 39.4699
                      longitude: -0.3763
                      radiusKm: 10
                    categories: ["4STAR", "5STAR"]
                  pageSize: 50
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HotelFilterResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/hotels/by-provider-codes:
    post:
      summary: Get hotels by provider-native codes
      description: |
        Returns full hotel content for provider-native hotel IDs (the provider's own property id).
        Use for agencies that store supplier property ids directly. See docs/AGENCY_CONTENT_DOWNLOAD.md.
      operationId: getHotelsByProviderCodes
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HotelsByProviderCodesRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HotelsByProviderCodesResponse'
        '400':
          description: Bad request (missing providerCode or hotelCodes)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/hotels/changed-since:
    post:
      summary: Get hotels whose content changed since an instant
      description: |
        Incremental content feed. Returns the same merged payload as `by-base-codes`, restricted to
        hotels whose content actually changed after `since`, plus a marker of what changed
        (`hotel`, `media`, `descriptions`, `rooms`).

        **What "changed" means here.** The feed reads a per-hotel content fingerprint, not a row
        timestamp. Every ingestion run rewrites the hotel rows whether or not a field moved, so a
        feed keyed on the row timestamp would return the whole catalogue after each sync. Only a
        hotel whose fingerprint differs appears here.

        **How to follow it.** Call with `since` set to the last checkpoint you stored. While the
        response carries a `cursor`, call again passing it back to get the next page; the last page
        has no `cursor`. Store `checkpointAt` as the next `since`. Changes are ordered oldest first,
        so an interrupted run resumes without gaps.

        Hotels that changed but have no base code yet are skipped and reported in `warnings`
        (`WARN_UNMAPPED_SKIPPED`); they arrive in a later page once mapping catches up.

        **Removals.** Properties a supplier stopped sending are soft-deleted (`hotels.deleted_at`)
        and appear in `deleted[]`, paged independently with `deletedCursor`. Omit `parts` to receive
        both live changes and removals; pass `parts: ["deleted"]` for removals only.

        **First call per provider.** Fingerprints are computed while loading, so a provider that
        has not been synced since the feed was enabled has none stored yet. Its first sync after
        that reports its whole catalogue once, and every later one reports only real changes. Plan
        the first run as a full read; a provider synced fortnightly reaches steady state after at
        most one cycle.
      operationId: getHotelsChangedSince
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HotelsChangedSinceRequest'
            examples:
              firstPage:
                summary: First call after a fortnight
                value:
                  since: '2026-08-01T00:00:00Z'
                  providerCode: example
                  maxSize: 100
              onlyPhotos:
                summary: Only properties whose photos changed
                value:
                  since: '2026-08-01T00:00:00Z'
                  parts: [media]
              nextPage:
                summary: Continuing from the previous page
                value:
                  since: '2026-08-01T00:00:00Z'
                  cursor: MjAyNi0wOC0xNFQxMDozMDowMFp8ZXhwcnxILTQy
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HotelsChangedSinceResponse'
        '400':
          description: Bad request (missing or malformed `since`, unknown part, invalid cursor)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/hotels/search:
    post:
      summary: Search hotels (lightweight catalog)
      description: |
        Returns a minimal list of hotels for UI search (base code, provider code, name, facets, city).
        Requires **providerCode** and at least one of **destination** (canonical city place UUID) or **countryCode** (ISO-3166-1 alpha-2).
        Use **cursor** (provider hotel code) for keyset pagination; **nextCursor** is returned when more rows exist.
        **limit** default 20, max 500; **offset** max 5000.
      operationId: searchHotelsCatalog
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HotelsSearchRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HotelsSearchResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/hotels/autocomplete:
    post:
      summary: Hotel name typeahead (cross-provider, ranked)
      description: |
        Cross-provider hotel name autocomplete ranked by trigram similarity (prefix matches first).
        Results are grouped by **baseCode** so the same physical hotel across providers appears once,
        with every (providerCode, providerHotelCode) listed under **providers**. Unmapped hotels
        (no baseCode) are returned individually.
        **query** requires at least 2 characters. Optional **countryCode** / **providerCode** scope.
        **limit** default 20, max 50.
      operationId: autocompleteHotels
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HotelsAutocompleteRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HotelsAutocompleteResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/chains/search:
    post:
      summary: Search hotel chains (typeahead catalog over provider_chains)
      description: |
        Typeahead search over the provider_chains catalog (one row per (providerCode, chainCode)).
        Returns **exact**, **prefix**, **contains** and **fuzzy** (pg_trgm) matches in a single call,
        grouped by canonical chain name so each item lists all (providerCode, chainCode) pairs that
        map to the same chain.

        The client sends any Unicode form (case, diacritics). The server canonicalizes `q` using
        the same pipeline as the stored column (NFD -> drop combining marks -> NFC -> lowercase)
        so `Hôtel` and `hotel` match the same rows.

        When `mode=suggest` and `items` is empty the response includes a low-threshold `suggestions[]`
        ("did you mean?") computed with a 0.15 trigram similarity cutoff.

        Response is cacheable: `ETag` + `Cache-Control: private, max-age=60`. Clients can send
        `If-None-Match` to get `304 Not Modified`.

        After getting the `chainCode` for a provider, call `POST /content/hotels/v1/hotels/search`
        with `providerCode` + `chainCode` to list hotels for that chain.
      operationId: searchHotelChains
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChainsSearchRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChainsSearchResponse'
        '304':
          description: Not Modified (ETag matched)
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/chains/masters:
    get:
      summary: List Bundleport-level master chains
      description: |
        Returns the canonical Bundleport-level chain view. Each master groups one or more
        (providerCode, chainCode) pairs that the cross-provider consensus job has linked.
        Optional `name` filter performs a substring match against the canonical
        (NFD -> drop combining marks -> NFC -> lowercase) name; pagination is keyset on
        `masterChainId` and survives concurrent writes.
      operationId: listMasterChains
      tags:
        - Hotels - Content
      parameters:
        - in: query
          name: name
          required: false
          schema:
            type: string
            maxLength: 100
          description: Substring to filter masters by canonical chain name.
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 200
        - in: query
          name: cursor
          required: false
          schema:
            type: string
          description: Opaque base64url cursor returned by the previous page.
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MasterChainListResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/chains/masters/{id}:
    get:
      summary: Get one master chain by ID
      description: |
        Returns the canonical Bundleport-level chain and its members
        (one per (providerCode, chainCode)). `hotelCount` is aggregated across members.
      operationId: getMasterChain
      tags:
        - Hotels - Content
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            maxLength: 64
          description: Master chain UUID as returned by /chains/masters or /chains/search.
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MasterChainDetailResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Master chain not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/chains/masters/{id}/hotels:
    get:
      summary: List hotels of a master chain (direct + transitive via base_code)
      description: |
        Returns the hotels that belong to the master chain identified by `id`,
        grouped by `baseCode`. A hotel is included when:

          * its own (providerCode, chainCode) carries `master_chain_id = id`
            (`source = "direct"`), or
          * it shares a `mapping_hotels.base_code` with a direct member, the
            base_code is **not** in conflict quarantine, and every direct
            member of that base_code resolves to the same master
            (`source = "transitive"`).

        `base_code`s that have been flagged as conflicting (e.g. a mapping
        that collapses hotels of two different brands under the same base_code)
        are silently skipped.

        Pagination is keyset on `baseCode` (ASC). `nextCursor` is an opaque
        base64url token; clients echo it back in `?cursor=...` to fetch the
        next page. A missing `nextCursor` means "no more rows".
      operationId: listMasterChainHotels
      tags:
        - Hotels - Content
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: string
            minLength: 36
            maxLength: 36
          description: Master chain UUID (canonical 8-4-4-4-12 form).
        - in: query
          name: limit
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
          description: Maximum number of DISTINCT baseCodes to return.
        - in: query
          name: cursor
          required: false
          schema:
            type: string
          description: Opaque pagination cursor returned by a previous response.
      responses:
        '200':
          description: Successful response
          headers:
            ETag:
              description: Weak validator; clients may send it back in `If-None-Match` to receive 304.
              schema: { type: string }
            Cache-Control:
              description: Always `private, max-age=30` for this endpoint.
              schema: { type: string }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MasterChainHotelsResponse'
        '304':
          description: Not Modified (client's ETag still valid)
        '400':
          description: Bad request (invalid UUID or cursor)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Master chain not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '504':
          description: Gateway Timeout (DB work exceeded 15s)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/chains/overrides:
    post:
      summary: Upsert a manual chain-name override (admin)
      description: |
        Admin-only endpoint. Creates or updates a curated canonical name for a (providerCode, chainCode)
        that wins over the value supplied by the provider sync. Useful when the provider returns
        "MAR" but the brand is "Marriott International".

        `createdBy` is captured from the authenticated user (JWT email claim) automatically.
      operationId: upsertChainOverride
      tags:
        - Chains Admin
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChainOverrideUpsertRequest'
      responses:
        '204':
          description: Override upserted
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
    delete:
      summary: Delete a manual chain-name override (admin)
      operationId: deleteChainOverride
      tags:
        - Chains Admin
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChainOverrideDeleteRequest'
      responses:
        '204':
          description: Override deleted
        '404':
          description: Override not found
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
    get:
      summary: List manual chain-name overrides (admin)
      operationId: listChainOverrides
      tags:
        - Chains Admin
      security:
        - BearerAuth: []
      parameters:
        - in: query
          name: providerCode
          schema:
            type: string
          description: Filter by provider code (lowercase).
        - in: query
          name: cursor
          schema:
            type: string
          description: Opaque keyset cursor returned by the previous page.
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChainOverrideListResponse'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden

  /content/hotels/v1/chains/suggestions:
    get:
      summary: List chain-name auto-enrichment suggestions (admin)
      description: |
        Admin-only review queue populated by the nightly `chainNameEnrichmentJob`.
        Pagination uses an opaque keyset cursor on `(computedAt DESC, providerCode, chainCode, source)`.
      operationId: listChainSuggestions
      tags:
        - Chains Admin
      security:
        - BearerAuth: []
      parameters:
        - in: query
          name: status
          schema:
            type: string
            enum: [pending, auto_published, accepted, rejected, superseded]
          description: Filter by suggestion status.
        - in: query
          name: providerCode
          schema:
            type: string
          description: Filter by provider code (lowercase).
        - in: query
          name: source
          schema:
            type: string
            enum: [inference, cross_provider]
          description: Filter by suggester source.
        - in: query
          name: cursor
          schema:
            type: string
          description: Opaque keyset cursor returned by the previous page.
        - in: query
          name: limit
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChainSuggestionListResponse'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden

  /content/hotels/v1/chains/suggestions/accept:
    post:
      summary: Accept a chain-name suggestion (admin)
      description: |
        Promotes a suggestion to a manual override in a single transaction. The optional
        `nameOverride` lets the admin tweak the candidate name before promotion.
        `reviewedBy` is captured from the authenticated user (JWT email claim).
      operationId: acceptChainSuggestion
      tags:
        - Chains Admin
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChainSuggestionAcceptRequest'
      responses:
        '204':
          description: Suggestion accepted
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Suggestion not found
        '409':
          description: Suggestion already accepted

  /content/hotels/v1/chains/suggestions/reject:
    post:
      summary: Reject a chain-name suggestion (admin)
      description: |
        Flags the suggestion as `rejected`. Idempotent: the nightly job will only
        regenerate a rejected suggestion when the candidate name actually changes.
      operationId: rejectChainSuggestion
      tags:
        - Chains Admin
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChainSuggestionRejectRequest'
      responses:
        '204':
          description: Suggestion rejected
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          description: Unauthorized
        '403':
          description: Forbidden
        '404':
          description: Suggestion not found

  /content/hotels/v1/hotels/by-destination:
    post:
      summary: Get hotels by destination (canonical city)
      description: |
        Returns hotels whose `canonical_city_id` is in the catchment of
        `destination.canonicalCityId` (any place type: CITY, REGION, ZONE).

        Catchment (same function as advertised `hotelCount` on search-destination):
        - dense CITY (≥2500 mapped hotels): that city only
        - thin CITY: same-country neighbours within 30 km, unless a namesake tourism ZONE exists
        - REGION / admin ZONE / district ZONE (parent CITY): `parent_id` descendants
        - tourism ZONE with overlay relationships: those member cities
        - tourism ZONE with no overlay and no curated radius: empty (no 50 km halo)
        - landmark ZONE with `spatial_radius_km`: that radius only, if the tree is empty

        **outputCodeType:**
        - `"provider"` (default): Each hotel has **providerHotelCode** only.
        - `"bundleport"`: Each hotel has **providerHotelCode** and **bundleportHotelCode** (base code) when mapped.
        
        **When outputCodeType is "bundleport" and scope.providerCode is omitted:** hotels from all providers are returned and mappings are resolved **per provider**. The internal key used for bundleport codes is `providerCode:providerHotelCode` (e.g. `example:12345`). Stats (mapped/unmapped) are aggregated across providers.
      operationId: getHotelsByDestination
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HotelsByDestinationRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HotelsByDestinationResponse'
        '400':
          description: Bad request (e.g. missing destination.canonicalCityId)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/hotels/by-coordinates:
    post:
      summary: Get hotels by coordinates (point + radius)
      description: |
        Returns hotels within a radius (km) of a point, ordered by distance. Same response shape as by-destination,
        with an additional **distanceKm** per hotel. Use when you have a lat/lon (e.g. map center or user location)
        and want a list of nearby hotels with codes and optional bundleport mapping.
      operationId: getHotelsByCoordinates
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HotelsByCoordinatesRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HotelsByCoordinatesResponse'
        '400':
          description: Bad request (e.g. invalid coordinates or radius)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/destinations:
    post:
      summary: Get list of destinations
      description: Retrieves a paginated list of destinations based on search query
      operationId: getDestinations
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DestinationsRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DestinationsResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/boards:
    post:
      summary: Get list of boards
      description: Retrieves a list of boards (meal plans) based on search query
      operationId: getBoards
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/BoardsRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BoardsResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/categories:
    post:
      summary: Get list of categories
      description: Retrieves a list of categories based on search query
      operationId: getCategories
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CategoriesRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CategoriesResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/rooms:
    post:
      summary: Get list of rooms
      description: >
        Retrieves a paginated list of rooms. The request must be scoped: `query` is
        mandatory and has to carry at least one of `connectionCode`, `providerCode`,
        `roomCodes` or `hotelCodes`. An unscoped or unwrapped body is rejected with 400
        rather than served as an arbitrary page of the whole multi-provider catalog.
      operationId: getRooms
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RoomsRequest'
            examples:
              scopedByHotel:
                summary: Valid - rooms of one hotel for one provider
                value:
                  query:
                    providerCode: example
                    hotelCodes: ['11677770']
                    maxSize: 50
              missingQueryWrapper:
                summary: Rejected - fields sent without the query wrapper
                value:
                  providerCode: example
                  hotelCodes: ['11677770']
              unscopedQuery:
                summary: Rejected - query with no scoping field
                value:
                  query:
                    maxSize: 50
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoomsResponse'
        '400':
          description: >
            Bad request: missing `query`, no scoping field, an unknown `connectionCode`,
            or a pagination token issued for a different query.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/metadata:
    post:
      summary: Get metadata
      description: Retrieves provider metadata based on search query
      operationId: getMetadata
      tags:
        - Hotels - Content
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MetadataRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MetadataResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scopes/audit:
    post:
      summary: Get scope audit history
      description: Retrieves audit history for content scope changes
      operationId: getScopeAuditHistory
      tags:
        - Content - Statistics
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScopeAuditRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScopeAuditResponse'
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scopes/stats:
    post:
      summary: Get scope statistics
      description: Retrieves aggregated statistics for content scope changes by connection code
      operationId: getScopeStats
      tags:
        - Content - Statistics
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScopeStatsRequest'
      responses:
        '200':
          description: Successful response
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScopeStatsResponse'
        '400':
          description: Bad request
        '500':
          description: Internal server error

  /content/hotels/v1/stats:
    get:
      summary: Get content counts by connection
      description: >
        Retrieves the count of content objects (hotels, rooms, destinations, boards, categories)
        for a given connection code. If no connection code is provided, it returns counts for all content.
        The counts are filtered by the provider associated with the connection code.
      operationId: getContentStats
      tags:
        - Content - Statistics
      parameters:
        - name: connectionCode
          in: query
          required: false
          schema:
            type: string
          description: Optional connection code to filter statistics by. If provided, counts are filtered by the provider associated with this connection.
      responses:
        '200':
          description: Successful response with content counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  hotels:
                    type: integer
                    description: Number of hotels
                    example: 150
                  rooms:
                    type: integer
                    description: Number of rooms
                    example: 450
                  destinations:
                    type: integer
                    description: Number of destinations
                    example: 25
                  boards:
                    type: integer
                    description: Number of boards (meal plans)
                    example: 12
                  categories:
                    type: integer
                    description: Number of categories
                    example: 8
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/export/catalog:
    get:
      summary: Export catalog snapshot (NDJSON)
      description: |
        Streams a catalog snapshot as NDJSON for agency batch consumption.
        Each line is one hotel with base code, providerPropertyIds (e.g. {"example": "..."} when that provider is selected),
        country, canonical city place id, updatedAt, and (by default) content-signal counts
        (`mediaCount`, `roomCount`) so agencies can triage which hotels have rich content.
        Full content is retrieved via by-base-codes / by-provider-codes.
        Use `since` (RFC3339) for incremental exports.
      operationId: exportCatalog
      tags:
        - Content - Statistics
      parameters:
        - name: provider
          in: query
          required: false
          schema:
            type: string
          description: Filter by provider code issued for the connection.
        - name: since
          in: query
          required: false
          schema:
            type: string
            format: date-time
          description: Only hotels with updated_at >= since (RFC3339).
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            minimum: 1
            maximum: 5000
            default: 1000
          description: Max rows per request (paginate with since + updatedAt cursor).
        - name: include
          in: query
          required: false
          schema:
            type: string
            enum: [counts, none]
            default: counts
          description: counts (default) adds mediaCount/roomCount per row; none omits them for a leaner index.
      responses:
        '200':
          description: >-
            NDJSON stream (application/x-ndjson). To page, send the `code` of the
            last line back as `after`; the last page is the one that returns fewer
            lines than the requested `limit`. X-Export-Count is not returned: it
            was only ever an HTTP trailer, which the gateway does not forward.
          headers:
            X-Export-Schema:
              schema:
                type: string
              description: Export schema version (catalog-v1).
          content:
            application/x-ndjson:
              schema:
                type: string
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/metrics/coverage:
    get:
      summary: Content coverage metrics by provider
      description: |
        Returns per-provider counts for hotels/rooms and how many carry
        descriptions/images/beds. Use for content health monitoring.

        The report is expensive to build (tens of seconds), so it is served from a snapshot
        refreshed every 10 minutes and no request ever computes it. Immediately after a
        restart, before the first snapshot exists, the endpoint answers `503` with
        `Retry-After` instead of holding the connection open.

        Read `hotelsWithUsefulDescription` next to `hotelsWithDescription`: a provider can
        score full description coverage while every row is just the hotel name repeated.

        Pass `media=true` to also include `hotelsWithJsonbMedia`, `hotelsWithGeo` and
        `lastUpdated`. That variant is computed inline (full scan of the hotels table, ~45s)
        and is not cached.
      operationId: getContentCoverage
      tags:
        - Content - Statistics
      parameters:
        - name: media
          in: query
          required: false
          description: Include JSONB media/geo counts (slow full-table scan, ~45s).
          schema:
            type: boolean
            default: false
      responses:
        '200':
          description: Coverage stats per provider
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
                  cached:
                    type: boolean
                    description: >
                      True when served from the in-process snapshot (refreshed every 10
                      minutes). Only a cold start or `media=true` runs the query inline.
                  mappingQuality:
                    allOf:
                      - $ref: '#/components/schemas/MappingQuality'
                    description: >
                      Base-code fragmentation counters. Omitted when the detector has never
                      run. Also available on its own at `/metrics/mapping-quality`.
                  providers:
                    type: array
                    items:
                      type: object
                      properties:
                        providerCode:
                          type: string
                        hotelsTotal:
                          type: integer
                        hotelsWithDescription:
                          type: integer
                        pctHotelsWithDescription:
                          type: number
                        hotelsWithUsefulDescription:
                          type: integer
                          description: >
                            Hotels whose description is more than a placeholder: longer than 80
                            characters and not just the hotel name repeated. A provider where
                            this is far below hotelsWithDescription ships names, not prose.
                        pctHotelsWithUsefulDescription:
                          type: number
                        sourceEmpty:
                          type: boolean
                          description: >
                            True when the provider ships neither media nor descriptions for any
                            hotel: the gap is at the source, not in our ingestion. Computed on
                            the background refresh, so it is absent until the first one lands.
                        roomsTotal:
                          type: integer
                        roomsWithImages:
                          type: integer
                        roomsWithDescriptions:
                          type: integer
                        roomsWithBeds:
                          type: integer
                        pctRoomsWithImages:
                          type: number
                        hotelsWithJsonbMedia:
                          type: integer
                          description: Only present when media=true.
                        hotelsWithGeo:
                          type: integer
                          description: Only present when media=true.
                        lastUpdated:
                          type: string
                          format: date-time
                          description: Only present when media=true.
        '503':
          description: >
            The first snapshot is still being computed. The work continues in the background;
            retry after the interval in the Retry-After header.
          headers:
            Retry-After:
              schema:
                type: integer
              description: Seconds to wait before retrying.
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/metrics/mapping-quality:
    get:
      summary: Base code fragmentation counters
      description: |
        Reports how many groups of base codes still describe the same physical hotel and how
        many of those identifiers are redundant. Watch it to detect the matcher minting
        duplicate identifiers before a consumer notices repeated properties.

        Served from the same 10 minute snapshot as `/metrics/coverage`.
      operationId: getMappingQuality
      tags:
        - Hotels - Content
      responses:
        '200':
          description: Fragmentation counters
          content:
            application/json:
              schema:
                type: object
                properties:
                  available:
                    type: boolean
                    description: False when the fragmentation detector has never run.
                  cached:
                    type: boolean
                  mappingQuality:
                    $ref: '#/components/schemas/MappingQuality'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/mappings/base-code-aliases:
    get:
      summary: Retired base codes and their survivors
      description: |
        Lists base codes retired by a consolidation together with the code that replaced them.

        Retired codes keep resolving server-side, so requests using them never fail. This
        endpoint exists for the other half of the problem: a consumer that stored the old
        identifier has no way to learn it moved. Poll with `since` to reconcile a local cache.
      operationId: listBaseCodeAliases
      tags:
        - Hotels - Content
      parameters:
        - name: since
          in: query
          required: false
          description: Only aliases recorded at or after this RFC3339 timestamp.
          schema:
            type: string
            format: date-time
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 200
            maximum: 1000
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Retired base codes
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
                  aliases:
                    type: array
                    items:
                      type: object
                      properties:
                        oldBaseCode:
                          type: string
                          description: Superseded code, still accepted on input.
                        baseCode:
                          type: string
                          description: Surviving code, what the API returns from now on.
                        reason:
                          type: string
                        createdAt:
                          type: string
                          format: date-time
                        note:
                          type: string
                          nullable: true
        '400':
          description: Invalid since parameter
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/amenities/taxonomy:
    get:
      summary: Canonical amenity dictionary
      description: |
        Publishes the canonical amenity codes behind the normalized `amenities` returned on
        hotels, so an integrator can translate them into their own vocabulary instead of
        reverse-engineering the codes from sample payloads.

        `topRank`, when present, is the position in the curated shortlist used for the
        `topAmenities` field on hotels.
      operationId: getAmenityTaxonomy
      tags:
        - Hotels - Content
      parameters:
        - name: category
          in: query
          required: false
          description: Restrict to one canonical category (case-insensitive).
          schema:
            type: string
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 200
            maximum: 1000
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Canonical amenity codes
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
                  amenities:
                    type: array
                    items:
                      type: object
                      properties:
                        code:
                          type: string
                        name:
                          type: string
                          nullable: true
                        nameEn:
                          type: string
                          nullable: true
                        nameEs:
                          type: string
                          nullable: true
                        category:
                          type: string
                          nullable: true
                        subcategory:
                          type: string
                          nullable: true
                        topRank:
                          type: integer
                          nullable: true
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/rooms/taxonomy:
    get:
      summary: Canonical room type codes
      description: |
        Publishes distinct room `baseCode` values from `mapping_rooms` with optional usage
        counts, so an integrator can enumerate the room-type vocabulary behind
        `bundleportRoomCode` without reverse-engineering sample payloads.

        Display names live in MappingsDB `base_rooms` and are not mirrored into ContentDB, so
        `name` is typically null. See `docs/ROOM_TAXONOMY.md`.
      operationId: getRoomTaxonomy
      tags:
        - Hotels - Content
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 200
            maximum: 1000
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Room type codes with usage counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  count:
                    type: integer
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
                  rooms:
                    type: array
                    items:
                      type: object
                      properties:
                        baseCode:
                          type: string
                        name:
                          type: string
                          nullable: true
                          description: Usually null; names are not stored in ContentDB mapping_rooms.
                        usageCount:
                          type: integer
                          description: Number of mapping_rooms rows for this base_code.
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/hotels/{baseCode}/provider-codes:
    get:
      summary: Provider hotel codes for a base code
      description: |
        Returns all provider hotel codes mapped to a Bundleport base code, each with the
        confidence of that match so you can apply your own threshold (for example accept
        above 90, review 70-90, discard below) instead of taking every match on trust.

        `confidencePct` absent means the pair predates the confidence table: treat it as
        unknown, not as zero. A retired base code resolves to its survivor automatically.
      operationId: getHotelProviderCodes
      tags:
        - Hotels - Content
      parameters:
        - name: baseCode
          in: path
          required: true
          schema:
            type: string
          description: Bundleport base code (mapping_hotels.base_code).
      responses:
        '200':
          description: Provider codes for the base code
          content:
            application/json:
              schema:
                type: object
                properties:
                  baseCode:
                    type: string
                  count:
                    type: integer
                  providerCodes:
                    type: array
                    items:
                      type: object
                      properties:
                        provider:
                          type: string
                        providerCode:
                          type: string
                        confidencePct:
                          type: integer
                          nullable: true
                          minimum: 0
                          maximum: 100
                          description: Match confidence. Absent when unknown.
                        matchSource:
                          type: string
                          nullable: true
                          enum: [generator, manual]
                          description: >
                            How the match was decided: `generator` for the automatic matcher,
                            `manual` for a human review, which is never overwritten.
                        reviewedAt:
                          type: string
                          format: date-time
                          nullable: true
        '400':
          description: Missing baseCode
        '500':
          description: Internal server error

  /content/hotels/v1/hotels/{baseCode}/rooms/provider-codes:
    get:
      summary: Room provider codes for a base code
      description: |
        Returns room mappings (provider room code → bundleportRoomCode) for all provider
        hotels linked to the given base code. Optional filter by provider query param.
      operationId: getHotelRoomProviderCodes
      tags:
        - Hotels - Content
      parameters:
        - name: baseCode
          in: path
          required: true
          schema:
            type: string
        - name: provider
          in: query
          required: false
          schema:
            type: string
          description: Filter mappings to a single provider.
      responses:
        '200':
          description: Room mappings
          content:
            application/json:
              schema:
                type: object
                properties:
                  baseCode:
                    type: string
                  count:
                    type: integer
                  rooms:
                    type: array
                    items:
                      type: object
                      properties:
                        provider:
                          type: string
                        providerRoomCode:
                          type: string
                        bundleportRoomCode:
                          type: string
        '400':
          description: Missing baseCode
        '500':
          description: Internal server error

  /content/hotels/v1/health:
    get:
      summary: Health check
      description: Returns the health status of the Content API
      operationId: healthCheck
      tags:
        - System
      security: []
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: OK

  /content/hotels/v1/health/db:
    get:
      summary: Database health check
      description: Returns the health status of the database connection
      operationId: databaseHealthCheck
      tags:
        - System
      responses:
        '200':
          description: Database connection status
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/metrics/db:
    get:
      summary: Database metrics
      description: Returns database connection metrics and statistics
      operationId: getDatabaseMetrics
      tags:
        - System
      responses:
        '200':
          description: Database metrics
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/metrics/cache:
    get:
      summary: Cache metrics (JSON)
      description: |
        Returns in-process cache hit/miss counters per namespace (dest_search,
        hotel_autocomplete, popular_dest) and layer (memory L1, redis L2, miss),
        plus whether the Redis L2 layer is connected. Counters are per-pod.
      operationId: getCacheMetrics
      tags:
        - System
      responses:
        '200':
          description: Cache counters
          content:
            application/json:
              schema:
                type: object
                properties:
                  redisEnabled:
                    type: boolean
                  destSearch:
                    $ref: '#/components/schemas/CacheLayerStats'
                  hotelAutocomplete:
                    $ref: '#/components/schemas/CacheLayerStats'
                  popularDest:
                    $ref: '#/components/schemas/CacheLayerStats'

  /content/hotels/v1/metrics/prometheus:
    get:
      summary: Cache metrics (Prometheus exposition)
      description: |
        Cache hit/miss counters in Prometheus text exposition format
        (`content_cache_hits_total{namespace,layer}`). Intended to be scraped by
        a ServiceMonitor; Prometheus aggregates the per-pod counters server-side.
      operationId: getCachePrometheusMetrics
      tags:
        - System
      security: []
      responses:
        '200':
          description: Prometheus exposition text
          content:
            text/plain:
              schema:
                type: string

  /content/hotels/v1/openapi.json:
    get:
      summary: OpenAPI specification (JSON)
      description: Returns the OpenAPI specification in JSON format
      operationId: getOpenAPISpec
      tags:
        - System
      security: []
      responses:
        '200':
          description: OpenAPI specification
          content:
            application/json:
              schema:
                type: object
                example:
                  openapi: "3.0.0"
                  info:
                    title: Bundleport Hotel Content API
                    version: "1.0.0"

  /content/hotels/v1/swagger.json:
    get:
      summary: OpenAPI specification (JSON) - Swagger format
      description: Returns the OpenAPI specification in JSON format (Swagger compatible)
      operationId: getSwaggerSpec
      tags:
        - System
      security: []
      responses:
        '200':
          description: OpenAPI specification
          content:
            application/json:
              schema:
                type: object
                example:
                  openapi: "3.0.0"
                  info:
                    title: Bundleport Hotel Content API
                    version: "1.0.0"

  /content/hotels/v1/places/search:
    post:
      summary: Place autocomplete (low-level)
      description: |
        Low-level place search/autocomplete ranked by prefix, trigram similarity and full-text
        matching over canonical place names and their aliases. Returns a flat list of places
        (no contextual expansion or hotel-volume filtering). For destination UX prefer
        `POST /content/hotels/v1/places/search-destination`, which adds sub-areas, nearest
        airport, parent region, tourism zones and pre-computed hotel counts.
        The `placeId` returned here can be used directly as `destination.canonicalCityId`
        in `POST /hotels/by-destination`.
      operationId: searchPlaces
      tags:
        - Places
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PlacesSearchRequest'
      responses:
        '200':
          description: Ranked place results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlacesSearchResponse'
        '400':
          description: Bad request (e.g. invalid type or negative limit)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/places/search-destination:
    post:
      summary: Search destinations with contextual expansion
      description: |
        Smart destination search that returns a flat, relevance-ranked list of places.
        Ranking is owned by this API — clients must not re-sort results except for
        display grouping. Order is: exact query pin (destination_query_pins, EXACT
        folded query only) → text-match priority → locale hint → query specificity
        → district ZONE behind sibling CITY → resort rule (thin CITY + same-name
        tourism ZONE; district/admin ZONEs do not count) → CITY-first tie-break
        → hotelCount.

        `hotelCount` is the catchment stored in place_hotel_counts. Thin vs dense
        and `nearbyIncluded` use direct hotel counts plus catalog namesake tourism
        ZONEs (admin_level NULL, parent is not CITY), not only rows in the current
        result set.

        `productLabel` is the buyer-facing kind (City, Province, District, Resort,
        Island, Region, Airport) — do not infer ADM1 vs ADM2 from `type` alone.
        `locale` is a ranking hint, never a filter. `countryCode` is ignored.

        The placeId can be used directly with POST /hotels/by-destination.
      operationId: searchDestination
      tags:
        - Places
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/DestinationSearchRequest'
      responses:
        '200':
          description: Ranked destination results
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DestinationSearchResponse'
        '400':
          description: Bad request (e.g. query too short)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/places/popular:
    get:
      summary: Popular destinations (instant typeahead bootstrap)
      description: |
        Returns the top tourist cities by hotel count for a market, used to
        bootstrap the client-side instant destination filter (0ms typeahead
        before the user triggers an API search). Responses are cached in Redis
        and carry weak ETag / Cache-Control headers for conditional requests.
      operationId: getPopularDestinations
      tags:
        - Places
      security: []
      parameters:
        - name: market
          in: query
          required: false
          schema:
            type: string
            default: ES
          description: ISO country code of the market (default ES).
        - name: locale
          in: query
          required: false
          schema:
            type: string
            default: es
          description: Locale hint (accepted for compatibility; does not change ranking yet).
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 30
            maximum: 300
          description: Max destinations to return (the client bundle requests 300).
      responses:
        '200':
          description: Popular destinations for the market
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PopularDestinationsResponse'
        '304':
          description: Not modified (matched If-None-Match ETag)
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/sync-logs:
    get:
      summary: Get sync logs
      description: Retrieves sync logs for content synchronization
      operationId: getSyncLogs
      tags:
        - Content - Statistics
      parameters:
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 50
          description: Maximum number of logs to return
        - name: connectionCode
          in: query
          required: false
          schema:
            type: string
          description: Optional connection code to filter logs
      responses:
        '200':
          description: Successful response with sync logs
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Scheduler endpoints - Public (accessible to non-superadmin users)
  /content/hotels/v1/scheduler/provider-sync/status/{providerCode}:
    get:
      summary: Get provider sync status
      description: Retrieves the synchronization status for a specific provider
      operationId: getProviderSyncStatus
      tags:
        - Scheduler
      parameters:
        - name: providerCode
          in: path
          required: true
          schema:
            type: string
          description: Provider code
      responses:
        '200':
          description: Provider sync status
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/connection-sync/status/{connectionCode}:
    get:
      summary: Get connection sync status
      description: Retrieves the synchronization status for a specific connection, including last sync time
      operationId: getConnectionSyncStatus
      tags:
        - Scheduler
      parameters:
        - name: connectionCode
          in: path
          required: true
          schema:
            type: string
          description: Connection code
      responses:
        '200':
          description: Connection sync status with last sync information
          content:
            application/json:
              schema:
                type: object
                properties:
                  connectionCode:
                    type: string
                  lastSync:
                    type: string
                    format: date-time
                    description: Last synchronization timestamp
                  status:
                    type: string
                    description: Current sync status
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/stats/overview:
    get:
      summary: Get sync statistics overview
      description: Retrieves aggregated synchronization statistics
      operationId: getSyncStatsOverview
      tags:
        - Scheduler
      responses:
        '200':
          description: Sync statistics overview
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  # Scheduler endpoints — internal / superadmin only (mark in ops runbooks; omit x-internal boolean here so OpenAPI doc generators treat path items as valid operation maps)
  /content/hotels/v1/scheduler/connections:
    get:
      summary: Get connections
      description: Retrieves all connections (internal use only)
      operationId: getConnections
      tags:
        - Scheduler
      responses:
        '200':
          description: List of connections
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/provider-sync/run-plan:
    post:
      summary: Run provider sync plan
      description: Executes a provider sync plan (internal use only)
      operationId: runProviderSyncPlan
      tags:
        - Scheduler
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
      responses:
        '200':
          description: Sync plan executed
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/provider-sync/connection-info/{providerCode}:
    get:
      summary: Get provider connection info
      description: Retrieves connection information for a provider (internal use only)
      operationId: getProviderConnectionInfo
      tags:
        - Scheduler
      parameters:
        - name: providerCode
          in: path
          required: true
          schema:
            type: string
          description: Provider code
      responses:
        '200':
          description: Provider connection information
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/provider-config:
    post:
      summary: Create or update provider config
      description: Creates or updates provider configuration (internal use only)
      operationId: createOrUpdateProviderConfig
      tags:
        - Scheduler
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
      responses:
        '200':
          description: Provider config updated
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/provider-config/{providerCode}/{connectionCode}:
    get:
      summary: Get provider config
      description: Retrieves provider configuration (internal use only)
      operationId: getProviderConfig
      tags:
        - Scheduler
      parameters:
        - name: providerCode
          in: path
          required: true
          schema:
            type: string
          description: Provider code
        - name: connectionCode
          in: path
          required: true
          schema:
            type: string
          description: Connection code
      responses:
        '200':
          description: Provider configuration
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/sync/history:
    get:
      summary: Get sync history
      description: Retrieves synchronization history (internal use only)
      operationId: getSyncHistory
      tags:
        - Scheduler
      responses:
        '200':
          description: Sync history
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/sync/history/{historyID}:
    get:
      summary: Get sync history details
      description: Retrieves detailed information for a specific sync history entry (internal use only)
      operationId: getSyncHistoryDetails
      tags:
        - Scheduler
      parameters:
        - name: historyID
          in: path
          required: true
          schema:
            type: string
          description: Sync history ID
      responses:
        '200':
          description: Sync history details
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/sync/last-by-provider-content-type:
    get:
      summary: Get last sync by provider and content type
      description: Retrieves the last synchronization timestamp for each provider and content type (internal use only)
      operationId: getLastSyncByProviderAndContentType
      tags:
        - Scheduler
      responses:
        '200':
          description: Last sync information
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/manual-sync/history/{connectionCode}:
    get:
      summary: Get manual sync history
      description: Retrieves manual synchronization history for a connection (internal use only)
      operationId: getManualSyncHistory
      tags:
        - Scheduler
      parameters:
        - name: connectionCode
          in: path
          required: true
          schema:
            type: string
          description: Connection code
      responses:
        '200':
          description: Manual sync history
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/sync/metrics/{connectionCode}:
    get:
      summary: Get sync metrics
      description: Retrieves synchronization metrics for a connection (internal use only)
      operationId: getSyncMetrics
      tags:
        - Scheduler
      parameters:
        - name: connectionCode
          in: path
          required: true
          schema:
            type: string
          description: Connection code
      responses:
        '200':
          description: Sync metrics
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /content/hotels/v1/scheduler/sync/cleanup-stuck:
    post:
      summary: Cleanup stuck syncs
      description: Cleans up stuck synchronization processes (internal use only)
      operationId: cleanupStuckSyncs
      tags:
        - Scheduler
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
      responses:
        '200':
          description: Stuck syncs cleaned up
          content:
            application/json:
              schema:
                type: object
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: "Gateway JWT after Authorization: ApiKey sk_* is exchanged, or a Clerk Bearer token."
    apiKeyAuth:
      type: apiKey
      in: header
      name: Authorization
      description: "Buyer header ApiKey sk_test_* or ApiKey sk_prod_*. Do not send X-API-Key."
  schemas:
    HotelsRequest:
      type: object
      properties:
        scope:
          $ref: '#/components/schemas/HotelListQuery'
          description: Preferred filter payload for hotel queries.
        query:
          $ref: '#/components/schemas/HotelListQuery'
          description: Standard filter payload for hotel queries.
        token:
          type: string
          description: >
            Pagination token for the next page of results.
            To retrieve the next page, include this token in the 'token' field of the subsequent request.
            Tokens are valid for 4 minutes and are tied to the original query criteria.
            If the query criteria change, a new token must be generated.

    HotelListQuery:
      type: object
      description: Search and filter criteria for hotel list. Use scope or query (prefer scope).
      properties:
        connectionCode:
          type: string
          description: Connection code; provider is resolved from it when using X-Environment.
        providerCode:
          type: string
          description: Provider scope; defaults to provider inferred from connectionCode when present.
        language:
          type: string
          example: es
          description: >-
            Language tag for the returned descriptions. The fallback chain is: exact tag, then the
            same base language (a request for es-MX accepts es), then English, then every language
            we hold. Omit it to receive all languages and filter client-side, which is the current
            behaviour. Content is ingested in English today, so most properties answer with en.
        hotelCodes:
          type: array
          items:
            type: string
          description: Hotel codes (provider-specific) to fetch.
        hotels:
          type: array
          items:
            type: string
          description: Alias for hotelCodes (frontend compatibility).
        providerHotelCodes:
          type: array
          items:
            type: string
          description: Provider-specific hotel codes.
        destinationCodes:
          type: array
          items:
            type: string
          description: Filter by destination/place codes (hotel_closest_destinations).
        destinationName:
          type: string
          description: Search by destination name (partial match, case-insensitive).
        countries:
          type: array
          items:
            type: string
          description: Filter by country codes (ISO 3166-1 alpha-2).
        ranks:
          type: array
          items:
            type: integer
        maxSize:
          type: integer
          description: Maximum number of results to return (0 = use default).
        hotelName:
          type: string
          description: Partial match search on hotel name (case-insensitive).
        category:
          type: string
          description: Single category filter (e.g. 3STAR). Prefer categories for multi-value.
        group:
          type: string
          deprecated: true
          description: Deprecated; use category instead.
        outputCodeType:
          type: string
          enum: [provider, bundleport]
          default: bundleport
          description: |
            Controls cross-reference ids on each hotel (parity with /hotels/by-destination and
            /hotels/by-coordinates):
            - "bundleport" (default): include externalIds (bundleportBaseCode, providerPropertyIds).
            - "provider": provider-native view; externalIds is omitted.
            Note: on this endpoint `code` and `providerHotelCode` are always the provider-native
            code. The Bundleport base code lives in `externalIds.bundleportBaseCode`, not in `code`.
        returnHotelCodes:
          type: array
          items:
            type: string
            enum: [base, provider]
          description: |
            Accepted for cross-endpoint consistency but a NO-OP here: POST /hotels always returns
            the base code in `externalIds.bundleportBaseCode`. The two-array form
            (hotelCodes.base[]/provider[]) is exclusive to POST /hotels/filter. When set, the
            response includes a WARN_PARAM_FILTER_ONLY warning.
        includeRoomDetail:
          type: boolean
          description: When true, hydrate each hotel's rooms[] with full detail (images, descriptions, beds, occupancy) inline instead of the basic room list. Opt-in; capped server-side.


    HotelsResponse:
      type: object
      properties:
        auditData:
          $ref: '#/components/schemas/AuditData'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/Error'
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/Warning'
        hotels:
          $ref: '#/components/schemas/HotelConnection'

    HotelFilterRequest:
      type: object
      description: |
        Filter hotel codes by criteria. Prefer connectionCodes (list of connection codes);
        providerCode is deprecated but still supported for backwards compatibility.
        At least one of connectionCodes or providerCode must be provided.
      properties:
        connectionCodes:
          type: array
          items:
            type: string
          description: Preferred. Connection codes; each resolved to provider for filtering.
        providerCode:
          type: string
          deprecated: true
          description: Deprecated; use connectionCodes. Provider code issued for the connection.
          example: "example"
        filters:
          $ref: '#/components/schemas/HotelFilters'
        codeMode:
          type: string
          enum: [base, provider]
          default: provider
          description: |
            How to interpret facet codes (categories, boardCodes, etc.):
            - "base": Bundleport/canonical codes
            - "provider": Provider-native codes (default)
        returnHotelCodes:
          type: array
          items:
            type: string
            enum: [base, provider]
          default: [base, provider]
          description: |
            Which hotel code types to return in the response.
            Can include "base", "provider", or both.
            When omitted, BOTH base and provider codes are returned so clients can
            build the mapping between them (parity with aggregator/integrations).
            Base codes are resolved via the provider->base mapping; when a hotel has
            no base mapping yet, its provider code is echoed in the base field.
            When both are returned, the response also includes `hotelCodes.pairs[]`
            with explicit {base, provider} objects so you don't rely on index alignment.
        pageSize:
          type: integer
          minimum: 1
          maximum: 5000
          default: 1000
          description: Number of results per page (max 5000)
        cursor:
          type: string
          description: Pagination cursor (hotel code to start after)

    HotelFilters:
      type: object
      description: Filter criteria for hotels
      properties:
        canonicalCityId:
          type: string
          description: Filter by canonical city ID
          example: "city_123456"
        countryCode:
          type: string
          description: Filter by country code (ISO 3166-1 alpha-2)
          example: "ES"
        category:
          type: string
          description: "Deprecated: Use categories instead. Single category filter (e.g., '3STAR')"
        categories:
          type: array
          items:
            type: string
          description: |
            Multi-category filter. Values can be:
            - Numeric: "1", "2", "3", "4", "5" (auto-converted to "1STAR", etc.)
            - Full format: "1STAR", "2STAR", "3STAR", "4STAR", "4STAR_SUP", "5STAR"
          example: ["3STAR", "4STAR", "5STAR"]
        boardCodes:
          type: array
          items:
            type: string
          description: Filter by board/meal plan codes. Returns hotels with ANY of these boards.
          example: ["AI", "HB", "BB"]
        roomCodes:
          type: array
          items:
            type: string
          description: Filter by room codes (future use)
        hotelName:
          type: string
          description: Partial match search on hotel name (case-insensitive)
          example: "Hilton"
        hasMedia:
          type: boolean
          description: When true, only hotels with media; when false, only without. Omit for no filter.
        amenityCodes:
          type: array
          items:
            type: string
          description: Hotel must have ALL of these amenity base codes.
        nearby:
          $ref: '#/components/schemas/NearbyFilter'

    NearbyFilter:
      type: object
      description: Geo-proximity filter
      required:
        - latitude
        - longitude
      properties:
        latitude:
          type: number
          format: double
          minimum: -90
          maximum: 90
          description: Latitude coordinate
          example: 39.4699
        longitude:
          type: number
          format: double
          minimum: -180
          maximum: 180
          description: Longitude coordinate
          example: -0.3763
        radiusKm:
          type: number
          format: double
          minimum: 0
          maximum: 500
          default: 50
          description: Search radius in kilometers (max 500km)
          example: 10

    HotelFilterResponse:
      type: object
      properties:
        hotelCodes:
          $ref: '#/components/schemas/HotelCodesResult'
        totalCount:
          type: integer
          description: Number of matching hotels returned
          example: 150
        nextCursor:
          type: string
          nullable: true
          description: Cursor for next page (null if no more results)
        filters:
          $ref: '#/components/schemas/AppliedFilters'

    HotelCodesResult:
      type: object
      description: |
        Hotel codes in requested formats. `base` and `provider` are position-aligned
        (base[i] corresponds to provider[i]). Prefer `pairs` to avoid relying on index
        alignment.
      properties:
        base:
          type: array
          items:
            type: string
          description: Bundleport/canonical hotel codes (if returnHotelCodes includes "base"). Position-aligned with provider[].
        provider:
          type: array
          items:
            type: string
          description: Provider-specific hotel codes (if returnHotelCodes includes "provider"). Position-aligned with base[].
        pairs:
          type: array
          description: |
            Explicit base<->provider pairing (index-independent). Present when both base and
            provider codes are requested. Use this to build the mapping without relying on
            array position.
          items:
            $ref: '#/components/schemas/HotelCodePair'

    HotelCodePair:
      type: object
      description: One base code tied to its provider code.
      required:
        - base
        - provider
      properties:
        base:
          type: string
          description: Bundleport/canonical hotel code (provider code echoed when unmapped).
        provider:
          type: string
          description: Provider-specific hotel code.

    AppliedFilters:
      type: object
      description: Tracks which filters were applied vs ignored
      properties:
        applied:
          type: array
          items:
            type: string
          description: List of filter names that were applied
          example: ["canonicalCityId", "categories", "boardCodes"]
        ignored:
          type: array
          items:
            type: string
          description: List of filters that were ignored (e.g., exceeded limits)
          example: ["pageSize>5000 (capped to 5000)"]

    HotelsSearchRequest:
      type: object
      description: |
        Flat search body. Scope: at least one of destination (canonical city UUID) or countryCode.
        providerCode is required (lowercase in DB).
      properties:
        destination:
          type: string
          description: Canonical city place UUID (same as filters.canonicalCityId on /hotels/filter).
          example: "2490e7e5-5cf4-474e-8ada-5c70781be6fc"
        countryCode:
          type: string
          description: ISO-3166-1 alpha-2 country code (hotels.canonical_country_code).
          example: "ES"
        hotelName:
          type: string
          description: Case-insensitive partial match (max 200 characters).
        providerCode:
          type: string
          description: Provider code (required).
          example: "example"
        category:
          type: string
          description: Single category / star code (e.g. 4STAR or numeric 4 → 4STAR).
        propertyType:
          type: string
          description: Exact match on hotels.property_type.
        chainCode:
          type: string
          description: Exact match on hotels.chain_code.
        limit:
          type: integer
          minimum: 1
          maximum: 500
          default: 20
        offset:
          type: integer
          minimum: 0
          maximum: 5000
          default: 0
        view:
          type: string
          enum: [basic]
          default: basic
          description: Only "basic" is supported.
        cursor:
          type: string
          description: Keyset pagination — return rows with provider hotel code greater than this value (same provider).

    HotelSearchItem:
      type: object
      properties:
        baseCode:
          type: string
          description: Bundleport base code from mapping_hotels when mapped; empty string if unmapped.
        providerHotelCode:
          type: string
        hotelName:
          type: string
        providerCode:
          type: string
        category:
          type: string
        propertyType:
          type: string
        chainCode:
          type: string
        city:
          type: string
        countryCode:
          type: string
        canonicalCityId:
          type: string
          description: hotels.canonical_city_id when set. Standard field name across the API.
        canonicalCityPlaceId:
          type: string
          description: Deprecated alias of canonicalCityId, kept for backward compatibility.

    HotelsSearchResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/HotelSearchItem'
        total:
          type: integer
          description: Total rows matching filters (same WHERE as items, without limit/offset/cursor).
        nextCursor:
          type: string
          nullable: true
          description: Provider hotel code cursor for the next page when more results exist.

    HotelsAutocompleteRequest:
      type: object
      required:
        - query
      properties:
        query:
          type: string
          minLength: 2
          maxLength: 200
          description: Hotel name typeahead text (min 2 characters).
          example: "barcelo"
        countryCode:
          type: string
          description: Optional ISO-3166-1 alpha-2 scope (hotels.canonical_country_code).
          example: "ES"
        providerCode:
          type: string
          description: Optional provider scope (lowercase). Omit for cross-provider search.
        limit:
          type: integer
          minimum: 1
          maximum: 50
          default: 20

    HotelAutocompleteItem:
      type: object
      properties:
        baseCode:
          type: string
          description: Bundleport base code when the hotel is mapped; omitted for unmapped hotels.
        hotelName:
          type: string
        category:
          type: string
        city:
          type: string
        countryCode:
          type: string
        canonicalCityId:
          type: string
          description: hotels.canonical_city_id when set.
        score:
          type: number
          format: double
          description: Trigram similarity (0..1) of the best-matching provider row in the group.
        providers:
          type: array
          description: Every provider occurrence that maps to this hotel (same baseCode).
          items:
            $ref: '#/components/schemas/HotelProviderEntry'

    HotelProviderEntry:
      type: object
      properties:
        providerCode:
          type: string
        providerHotelCode:
          type: string

    HotelsAutocompleteResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/HotelAutocompleteItem'
        total:
          type: integer
          description: Number of grouped suggestions returned.

    ChainsSearchRequest:
      type: object
      required:
        - q
      properties:
        q:
          type: string
          description: |
            Free-text query; 1..100 runes (>=2 runes when mode=suggest). Canonicalized server-side
            using the same pipeline as the stored column (NFD -> drop combining marks -> NFC -> lowercase).
          example: "marriott"
        providerCode:
          type: string
          description: Optional provider filter (lowercase). Omit for cross-provider search.
          example: "example"
        mode:
          type: string
          enum: [suggest, exact, prefix]
          default: suggest
          description: |
            - `suggest` (default): exact + prefix + contains + fuzzy (trigram) in a single query.
            - `exact`: only rows whose canonical name equals `q`.
            - `prefix`: only rows whose canonical name starts with `q`.
        limit:
          type: integer
          minimum: 1
          maximum: 100
          default: 20
    ChainSearchItem:
      type: object
      properties:
        chainName:
          type: string
          description: Canonical chain name (prefers override over provider value).
        matchType:
          type: string
          enum: [exact, prefix, contains, fuzzy]
        score:
          type: number
          format: float
          description: 0..1 ranking score (exact=1.0, fuzzy uses pg_trgm similarity).
        masterChainId:
          type: string
          description: >
            Bundleport-level canonical chain UUID. Present when the cross-provider
            consensus job has linked the members; absent when no master is assigned yet.
          nullable: true
        providers:
          type: array
          items:
            $ref: '#/components/schemas/ChainProviderEntry'
    ChainProviderEntry:
      type: object
      properties:
        providerCode:
          type: string
          example: "example"
        chainCode:
          type: string
          example: "2811"
    ChainSuggestion:
      type: object
      properties:
        chainName:
          type: string
        score:
          type: number
          format: float
    ChainsSearchResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ChainSearchItem'
        total:
          type: integer
          description: Number of grouped items returned (after limit).
        suggestions:
          type: array
          items:
            $ref: '#/components/schemas/ChainSuggestion'
          description: Present only when `items` is empty and `mode=suggest` (low-threshold trigram fallback).

    ChainOverrideUpsertRequest:
      type: object
      required:
        - providerCode
        - chainCode
        - chainName
      properties:
        providerCode:
          type: string
          example: "example"
        chainCode:
          type: string
          maxLength: 32
          example: "2811"
        chainName:
          type: string
          maxLength: 255
          description: Canonical name that wins over the sync value. HTML (< or >) is rejected.
          example: "Marriott International"
        note:
          type: string
          maxLength: 500
    ChainOverrideDeleteRequest:
      type: object
      required:
        - providerCode
        - chainCode
      properties:
        providerCode:
          type: string
        chainCode:
          type: string
    ChainOverrideItem:
      type: object
      properties:
        providerCode:
          type: string
        chainCode:
          type: string
        chainName:
          type: string
        note:
          type: string
          nullable: true
        createdBy:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    ChainOverrideListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ChainOverrideItem'
        nextCursor:
          type: string
          description: >
            Opaque base64url-encoded keyset cursor. Echo back verbatim in
            `?cursor=` to fetch the next page. Absent when there are no more
            pages.

    ChainSuggestionItem:
      type: object
      properties:
        providerCode:
          type: string
        chainCode:
          type: string
        source:
          type: string
          enum: [inference, cross_provider]
        candidateName:
          type: string
        confidence:
          type: number
          format: double
          minimum: 0
          maximum: 1
        sampleSize:
          type: integer
          minimum: 0
        status:
          type: string
          enum: [pending, auto_published, accepted, rejected, superseded]
        computedAt:
          type: string
          format: date-time
        reviewedAt:
          type: string
          format: date-time
        reviewedBy:
          type: string
        note:
          type: string
        support:
          type: string
          description: Raw JSON (as string) with the evidence used by the suggester. Opaque to the API.

    ChainSuggestionListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ChainSuggestionItem'
        nextCursor:
          type: string
          description: >
            Opaque base64url-encoded keyset cursor. Echo back verbatim in
            `?cursor=` to fetch the next page. Absent when there are no more
            pages.

    ChainSuggestionAcceptRequest:
      type: object
      required:
        - providerCode
        - chainCode
        - source
      properties:
        providerCode:
          type: string
        chainCode:
          type: string
        source:
          type: string
          enum: [inference, cross_provider]
        nameOverride:
          type: string
          description: Optional curated name; when omitted, the suggestion's candidate name is used.
        note:
          type: string

    ChainSuggestionRejectRequest:
      type: object
      required:
        - providerCode
        - chainCode
        - source
      properties:
        providerCode:
          type: string
        chainCode:
          type: string
        source:
          type: string
          enum: [inference, cross_provider]
        reason:
          type: string

    MasterChainSummary:
      type: object
      properties:
        masterChainId:
          type: string
          description: Bundleport-level canonical chain UUID.
        chainName:
          type: string
          description: Deterministic representative effective name across members.
        hotelCount:
          type: integer
          description: Sum of hotels across all (providerCode, chainCode) members.
        providerCount:
          type: integer
          description: Number of provider chain codes linked to this master.

    MasterChainProvider:
      type: object
      properties:
        providerCode:
          type: string
        chainCode:
          type: string
        chainName:
          type: string
          nullable: true
          description: Effective name for this member (override wins over provider value).

    MasterChainListResponse:
      type: object
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/MasterChainSummary'
        nextCursor:
          type: string
          description: Opaque base64url cursor for keyset pagination; absent on last page.

    MasterChainDetailResponse:
      type: object
      properties:
        masterChainId:
          type: string
        chainName:
          type: string
        hotelCount:
          type: integer
        providerCount:
          type: integer
        providers:
          type: array
          items:
            $ref: '#/components/schemas/MasterChainProvider'

    MasterChainHotelProvider:
      type: object
      description: |
        One (providerCode, providerHotelCode) entry under a shared baseCode.
        `source` distinguishes direct members (the hotel's own provider_chain
        carries the master_chain_id) from transitive ones (resolved through a
        shared base_code).
      properties:
        providerCode:
          type: string
        providerHotelCode:
          type: string
        hotelName:
          type: string
        source:
          type: string
          enum: [direct, transitive]

    MasterChainHotelGroup:
      type: object
      description: Group of (providerCode, providerHotelCode) entries that share the same baseCode.
      properties:
        baseCode:
          type: string
        hotels:
          type: array
          items:
            $ref: '#/components/schemas/MasterChainHotelProvider'

    MasterChainHotelsResponse:
      type: object
      properties:
        masterChainId:
          type: string
        items:
          type: array
          items:
            $ref: '#/components/schemas/MasterChainHotelGroup'
        nextCursor:
          type: string
          description: Opaque base64url cursor for keyset pagination; absent on last page.

    HotelsByDestinationRequest:
      type: object
      required:
        - destination
      description: |
        Request body for hotels by destination. When outputCodeType is "bundleport" and scope.providerCode is omitted,
        hotels from all providers are returned and Bundleport base-code mappings are resolved per provider (see path description).
      properties:
        scope:
          type: object
          description: Optional. Restrict to a single provider or connection; if omitted, hotels from all providers are returned.
          properties:
            providerCode:
              type: string
              description: Provider code issued for the connection. If omitted, all providers.
            connectionCode:
              type: string
              description: Connection code; provider is resolved from it.
        destination:
          type: object
          required:
            - canonicalCityId
          properties:
            canonicalCityId:
              type: string
              description: Canonical city ID (place UUID from places search). Required.
        outputCodeType:
          type: string
          enum: [provider, bundleport]
          default: provider
          description: |
            "provider" = only providerHotelCode per hotel.
            "bundleport" = providerHotelCode and bundleportHotelCode (base code) when mapped; without scope.providerCode, mappings are resolved per provider.
        paging:
          type: object
          properties:
            limit:
              type: integer
              minimum: 1
              maximum: 1000
              default: 100
            cursor:
              type: string
              description: Pagination cursor from previous response
        onlyBundleportRank:
          type: boolean
          default: false
          description: |
            If true, only return hotels in the bundleport rank list (source bundleport_rank_base).
            Ranked-only requests skip landmark spatial fallback.
        sort:
          type: string
          enum: [bundleport_rank]
          description: |
            Use bundleport_rank to order by Bundleport rank (best sellers first); omit for default order.
            sort=bundleport_rank also skips landmark spatial fallback.

    PlacesSearchRequest:
      type: object
      properties:
        query:
          type: string
          description: Search text (may be empty to list places of a given type/country)
        limit:
          type: integer
          default: 10
          description: Maximum number of results
        countryCode:
          type: string
          pattern: '^[A-Z]{2}$'
          description: ISO 3166-1 alpha-2 country code to restrict results
        type:
          type: string
          enum: [COUNTRY, REGION, CITY, ZONE, AIRPORT]
          description: Restrict results to a place type

    PlaceSearchResult:
      type: object
      properties:
        placeId:
          type: string
          format: uuid
          description: Canonical place ID (use as canonicalCityId for hotels/by-destination)
        canonicalCityId:
          type: string
          format: uuid
          description: Same as placeId; use for hotels/by-destination
        type:
          type: string
          description: COUNTRY, REGION, CITY, ZONE, AIRPORT
        name:
          type: string
        countryCode:
          type: string
          description: ISO 3166-1 alpha-2
        path:
          type: string
          description: Full breadcrumb path (e.g. "Madrid, Spain")
        population:
          type: integer
          format: int64
          description: GeoNames population (touristic relevance proxy)

    PlacesSearchResponse:
      type: object
      properties:
        places:
          type: array
          items:
            $ref: '#/components/schemas/PlaceSearchResult'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/Error'
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/Warning'

    DestinationSearchRequest:
      type: object
      required: [query]
      properties:
        query:
          type: string
          minLength: 2
          description: Search text
        limit:
          type: integer
          default: 10
          maximum: 20
          description: Maximum number of results
        locale:
          type: string
          description: |
            Caller market/UI locale (e.g. "es-ES", "de-AT", "pt-BR" or just "de").
            Non-restrictive ranking **hint**, never a filter: same-name places in the
            locale's country (Vienna AT vs US) and localized exonym matches are boosted,
            but search stays global and every relevant destination is still returned.
          example: "es-ES"
        countryCode:
          type: string
          pattern: '^[A-Z]{2}$'
          description: Deprecated. Kept for backward-compatibility; ignored (search is global). Use `locale` for market hints.

    DestinationSearchResponse:
      type: object
      properties:
        results:
          type: array
          items:
            $ref: '#/components/schemas/DestinationSearchResult'

    CacheLayerStats:
      type: object
      description: Hit/miss counters for one cache namespace (per-pod).
      properties:
        memory:
          type: integer
          description: L1 (in-process) hits
        redis:
          type: integer
          description: L2 (Redis) hits
        miss:
          type: integer
          description: Misses that fell through to the database
        total:
          type: integer
          description: memory + redis + miss
        hitRate:
          type: integer
          description: Hit rate in basis points (10000 = 100%)

    PopularDestinationsResponse:
      type: object
      properties:
        market:
          type: string
          description: ISO country code of the market
        locale:
          type: string
          description: Locale hint echoed back
        items:
          type: array
          items:
            $ref: '#/components/schemas/PopularDestinationItem'

    PopularDestinationItem:
      type: object
      properties:
        placeId:
          type: string
          description: Place UUID. Use as canonicalCityId in hotels/by-destination.
        name:
          type: string
          description: City display name
        countryCode:
          type: string
        country:
          type: string
          description: Country display name (from breadcrumb path)
        type:
          type: string
          enum: [CITY, REGION, ZONE, AIRPORT, COUNTRY, POI]
        path:
          type: string
          description: Full breadcrumb path
        hotelCount:
          type: integer
        aliases:
          type: array
          items:
            type: string
          description: Alternate spellings for prefix matching (e.g. Malaga/Málaga)

    DestinationSearchResult:
      type: object
      properties:
        placeId:
          type: string
          description: Place UUID. Use as canonicalCityId in hotels/by-destination.
        name:
          type: string
          description: Place display name
        type:
          type: string
          enum: [CITY, REGION, ZONE, AIRPORT, COUNTRY, POI]
          description: Place type
        path:
          type: string
          description: Full breadcrumb path excluding self (e.g. "Community of Madrid, Spain")
        hotelCount:
          type: integer
          description: |
            Catchment hotel count for this placeId. Same set as POST /hotels/by-destination
            (mapped catalog, not occupancy). 0 for airports and places without hotels.
        productLabel:
          type: string
          description: Buyer-facing kind (City, Province, District, Resort, Island, Region, Airport, Landmark). Prefer this over raw `type` in UI badges.
        adminLevel:
          type: integer
          description: GeoNames ADM level when the place is an administrative ZONE/REGION.
        nearbyIncluded:
          type: boolean
          description: True when a thin CITY expands to neighbouring cities in by-destination (same-country 30 km). False when a namesake tourism ZONE exists (catalog lookup, not only the current result set).
        catchmentRadiusKm:
          type: number
          format: double
          description: Explicit catchment radius in km when the product surface is not a single municipality.
        latitude:
          type: number
          format: double
          description: Center latitude
        longitude:
          type: number
          format: double
          description: Center longitude

    HotelsByDestinationResponse:
      type: object
      properties:
        hotels:
          type: array
          items:
            $ref: '#/components/schemas/HotelByDestResult'
        stats:
          $ref: '#/components/schemas/HotelsByDestStats'
        paging:
          type: object
          properties:
            nextCursor:
              type: string
              nullable: true
            hasMore:
              type: boolean

    HotelsByDestStats:
      type: object
      description: Mapped/Unmapped and byProvider only when outputCodeType=bundleport; otherwise total only.
      properties:
        total:
          type: integer
          description: Total hotels in destination
        mapped:
          type: integer
          description: Hotels with Bundleport mapping (bundleport only)
        unmapped:
          type: integer
          description: Hotels pending to map (bundleport only)
        byProvider:
          type: object
          additionalProperties:
            $ref: '#/components/schemas/ProviderStats'
          description: Per-provider total/mapped/unmapped (bundleport only)

    ProviderStats:
      type: object
      properties:
        total:
          type: integer
        mapped:
          type: integer
        unmapped:
          type: integer

    HotelByDestResult:
      type: object
      description: Every hotel in the destination is returned (mapped and unmapped). stats.unmapped are pending to map.
      properties:
        providerCode:
          type: string
          description: Provider code issued for the connection, to identify which hotels are pending to map per provider
        providerHotelCode:
          type: string
          description: Provider's hotel code
        bundleportHotelCode:
          type: string
          nullable: true
          description: Bundleport base code (when outputCodeType=bundleport and mapped)
        canonicalCityId:
          type: string
        hotelName:
          type: string
          nullable: true
        bundleportRank:
          type: integer
          nullable: true
          description: 1-based rank in bundleport rank list (e.g. bundleport_rank_base); present only when hotel is in the list
        inBundleportRank:
          type: boolean
          description: True when hotel is in the bundleport rank list

    HotelsByCoordinatesRequest:
      type: object
      required:
        - location
      properties:
        location:
          type: object
          required:
            - latitude
            - longitude
          properties:
            latitude:
              type: number
              format: double
              minimum: -90
              maximum: 90
            longitude:
              type: number
              format: double
              minimum: -180
              maximum: 180
            radiusKm:
              type: number
              format: double
              minimum: 0
              maximum: 500
              default: 50
              description: Search radius in kilometers
        scope:
          type: object
          properties:
            providerCode:
              type: string
            connectionCode:
              type: string
        outputCodeType:
          type: string
          enum: [provider, bundleport]
          default: provider
        paging:
          type: object
          properties:
            limit:
              type: integer
              minimum: 1
              maximum: 1000
              default: 100
            cursor:
              type: string

    HotelsByCoordinatesResponse:
      type: object
      properties:
        hotels:
          type: array
          items:
            $ref: '#/components/schemas/HotelByCoordinatesResult'
        stats:
          $ref: '#/components/schemas/HotelsByDestStats'
        paging:
          type: object
          properties:
            nextCursor:
              type: string
              nullable: true
            hasMore:
              type: boolean

    HotelByCoordinatesResult:
      type: object
      description: Same shape as by-destination plus distanceKm; includes providerCode for consistency.
      properties:
        providerCode:
          type: string
          description: Provider code issued for the connection
        providerHotelCode:
          type: string
        bundleportHotelCode:
          type: string
          nullable: true
        canonicalCityId:
          type: string
        hotelName:
          type: string
          nullable: true
        distanceKm:
          type: number
          format: double
          description: Distance from the request point to the hotel in km

    HotelConnection:
      type: object
      description: Paginated list of hotels with optional next-page token.
      properties:
        hotels:
          type: array
          items:
            $ref: '#/components/schemas/Hotel'
        count:
          type: integer
          description: Number of hotels in this response.
        token:
          type: string
          nullable: true
          description: >
            Pagination token for the next page. Include in the next request's 'token' field.
            Valid 4 minutes; tied to original query criteria.

    HotelsByBaseCodesRequest:
      type: object
      required:
        - baseCodes
      properties:
        baseCodes:
          type: array
          items:
            type: string
          minItems: 1
          description: Bundleport base codes (mapping_hotels.base_code). Required, non-empty.
        preferredContentSource:
          type: string
          description: Provider to use as primary content when available (e.g. VET, GOG).
        preferredContentSourceOrder:
          type: array
          items:
            type: string
          description: Fallback order for choosing primary when preferred has no content.
        includeAllSources:
          type: boolean
          description: When true, response includes for each hotel the array of contents per provider.
        language:
          type: string
          example: es
          description: >-
            Language tag for the returned descriptions, applied to the primary hotel and to every
            entry in sources. Fallback chain: exact tag, same base language, English, then every
            language we hold. Omit it to receive all languages.
        maxSize:
          type: integer
          description: Maximum number of base codes to resolve (default from config).

    HotelsByBaseCodesResponse:
      type: object
      properties:
        hotels:
          type: array
          items:
            $ref: '#/components/schemas/HotelByBaseCode'
        count:
          type: integer
          description: Number of hotels returned.

    HotelsChangedSinceRequest:
      type: object
      required:
        - since
      properties:
        since:
          type: string
          format: date-time
          example: '2026-08-01T00:00:00Z'
          description: >-
            RFC 3339 instant. Only hotels whose content changed strictly after it are returned.
            Required on purpose: a default would either ship the whole catalogue or skip changes
            without saying so.
        providerCode:
          type: string
          description: Narrows the feed to one provider.
        parts:
          type: array
          items:
            type: string
            enum: [hotel, media, descriptions, rooms, deleted]
          description: >-
            Narrows the feed to hotels where at least one of these parts changed. `hotel` covers the
            property fields (name, category, type, chain, board codes, fees, check-in/out, policies);
            `rooms` covers the room list and each room's texts, images and bed configuration.
            `deleted` selects the removals page (`deleted[]`). Omit the array to receive both live
            changes and removals. Any other value is rejected with 400 and the accepted list.
        cursor:
          type: string
          description: Opaque continuation token from the previous page. Pass it back untouched.
        deletedCursor:
          type: string
          description: >-
            Opaque continuation token for the removals page. Independent of `cursor` so live
            changes and deletions can finish at different rates.
        maxSize:
          type: integer
          default: 100
          maximum: 500
          description: Hotels inspected per page.
        language:
          type: string
          description: Language tag for the returned descriptions, same fallback as by-base-codes.
        preferredContentSource:
          type: string
          description: Provider to use as primary content when available, as in by-base-codes.
        preferredContentSourceOrder:
          type: array
          items:
            type: string
          description: Fallback order for choosing primary, as in by-base-codes.
        includeAllSources:
          type: boolean
          description: When true, each hotel includes the content of every provider merged into it.

    HotelsChangedSinceResponse:
      type: object
      properties:
        hotels:
          type: array
          items:
            $ref: '#/components/schemas/ChangedHotel'
        count:
          type: integer
          description: Number of hotels in this page.
        cursor:
          type: string
          description: >-
            Pass back to get the next page. Absent means the feed is caught up as of checkpointAt.
        checkpointAt:
          type: string
          format: date-time
          description: >-
            Change instant of the last hotel inspected. Store it and use it as `since` next time.
        warnings:
          type: array
          items:
            type: string
          description: >-
            Non-fatal notes. `WARN_UNMAPPED_SKIPPED` means some changed hotels have no base code yet
            and were left out of this page.
        deleted:
          type: array
          items:
            $ref: '#/components/schemas/DeletedHotel'
          description: >-
            Provider hotels soft-deleted after `since`. Absent when `parts` was set and did not
            include `deleted`.
        deletedCount:
          type: integer
          description: Number of removals in this page.
        deletedCursor:
          type: string
          description: >-
            Pass back as `deletedCursor` to get the next removals page. Absent when that page is
            complete.

    DeletedHotel:
      type: object
      properties:
        provider:
          type: string
          description: ContentDB provider code as stored, not a mapping alias.
        providerCode:
          type: string
          description: Provider-native hotel code.
        baseCode:
          type: string
          description: Bundleport base code when mapping still has it; omitted if unmapped.
        deletedAt:
          type: string
          format: date-time
          description: Instant `hotels.deleted_at` was set.

    ChangedHotel:
      allOf:
        - $ref: '#/components/schemas/HotelByBaseCode'
        - type: object
          properties:
            changedAt:
              type: string
              format: date-time
              description: Newest change among the providers merged into this base code.
            changedParts:
              type: array
              items:
                type: string
                enum: [hotel, media, descriptions, rooms]
              description: >-
                What moved inside the requested window, merged across providers. Empty means only
                the mapping changed; the content itself predates the window.
            changedProviders:
              type: array
              items:
                $ref: '#/components/schemas/ChangedProvider'
              description: >-
                The provider hotels that changed, so a consumer merging by base code knows which
                source to re-read.

    ChangedProvider:
      description: >-
        One provider hotel that changed. A provider published in several languages records a change
        per language; they are merged here, so a provider hotel appears once with the newest instant
        and the union of its changed parts.
      type: object
      properties:
        provider:
          type: string
        providerCode:
          type: string
        changedAt:
          type: string
          format: date-time
        changedParts:
          type: array
          items:
            type: string
            enum: [hotel, media, descriptions, rooms]

    HotelsByProviderCodesRequest:
      type: object
      required:
        - providerCode
        - hotelCodes
      properties:
        providerCode:
          type: string
          description: Content provider code issued for the connection.
        hotelCodes:
          type: array
          items:
            type: string
          minItems: 1
          description: Provider-native hotel IDs (the provider's own property id).
        language:
          type: string
          example: es
          description: >-
            Language tag for the returned descriptions. Fallback chain: exact tag, same base
            language, English, then every language we hold. Omit it to receive all languages.
        maxSize:
          type: integer
          description: Maximum codes per request (default 100, max 1000).

    HotelsByProviderCodesResponse:
      type: object
      properties:
        providerCode:
          type: string
        hotels:
          type: array
          items:
            $ref: '#/components/schemas/Hotel'
        count:
          type: integer
        notFound:
          type: array
          items:
            type: string
          description: Requested provider codes with no content in ContentDB.

    HotelByBaseCode:
      type: object
      description: One hotel result keyed by base code, with primary content and optional per-provider sources.
      properties:
        baseCode:
          type: string
          description: Bundleport base code.
        primary:
          $ref: '#/components/schemas/Hotel'
          nullable: true
          description: Content from preferred source or first available.
        sources:
          type: array
          items:
            $ref: '#/components/schemas/HotelSource'
          description: Content per provider when includeAllSources was true.
        memberCount:
          type: integer
          description: >
            How many providers were merged into this base code. A value of 1 means the property
            is only known through one supplier, which is also what a fragmented identifier looks
            like from outside: the same hotel split across several base codes.
        descriptionSource:
          type: string
          nullable: true
          description: >
            Provider the returned description came from. It is not always the primary provider:
            when the primary only carries a placeholder, prose is taken from another member of
            the base code.
        mediaSource:
          type: string
          nullable: true
          description: Sibling provider that filled media when the primary had none.
        amenitiesSource:
          type: string
          nullable: true
          description: Sibling provider that filled amenities when the primary had none.
        contactSource:
          type: string
          nullable: true
          description: Sibling provider that filled contact when the primary had none.
        chainSource:
          type: string
          nullable: true
          description: Sibling provider that filled chain code when the primary had none.

    HotelSource:
      type: object
      description: Content from a single provider for a base code.
      properties:
        provider:
          type: string
        providerCode:
          type: string
        hotel:
          $ref: '#/components/schemas/Hotel'

    Hotel:
      type: object
      description: |
        Hotel with flattened structure. Arrays (descriptions, boardCodes, amenities, media, rooms, mandatoryFee, closestDestinations)
        are always present; use empty array when none. Location and contact may be null.
        Geo enrichment fields (countryName, canonicalCityPlaceId, region, etc.) come from geo resolution when available.
      properties:
        code:
          type: string
          description: Hotel identifier (provider-specific code).
        hotelName:
          type: string
          description: Name of the hotel.
        descriptions:
          type: array
          items:
            $ref: '#/components/schemas/HotelDescription'
          description: Descriptions by language; always present, empty when none.
        category:
          type: string
          nullable: true
          description: Category code (e.g. 3STAR, 4STAR).
        propertyType:
          type: string
          nullable: true
        chainCode:
          type: string
          nullable: true
        boardCodes:
          type: array
          items:
            type: string
          description: Meal plan codes; always present.
        mandatoryFee:
          type: array
          items:
            $ref: '#/components/schemas/MandatoryFee'
          description: Mandatory fees; always present.
        rooms:
          type: array
          items:
            $ref: '#/components/schemas/RoomData'
          description: Room types; always present.
        amenities:
          type: array
          items:
            $ref: '#/components/schemas/Amenity'
          description: Amenities; always present.
        topAmenities:
          type: array
          items:
            $ref: '#/components/schemas/Amenity'
          description: Top amenities (ordered by top_amenities); always present.
        media:
          type: array
          items:
            $ref: '#/components/schemas/Media'
          description: Images/videos; always present.
        location:
          $ref: '#/components/schemas/Location'
          nullable: true
          description: Address from provider; always present, null when missing.
        contact:
          $ref: '#/components/schemas/Contact'
          nullable: true
          description: Contact info; always present, null when missing.
        providerCode:
          type: string
          description: Provider identifier; always present.
        connectionCodes:
          type: array
          items:
            type: string
          description: Connection codes where this hotel is available; always present.
        imageBaseUrl:
          type: string
          nullable: true
          description: Base URL to prepend to media URLs (from provider metadata). Omit or empty = use media URL as-is.
        imageURL:
          type: string
          nullable: true
          description: Alias for imageBaseUrl (e.g. by-base-codes clients).
        providerHotelCode:
          type: string
          description: Provider-specific hotel code.
        exclusiveDeal:
          type: boolean
        starRating:
          type: integer
          nullable: true
        starRatingSource:
          type: string
          nullable: true
          description: How starRating was resolved — "official" (provider property rating) or "category" (parsed from category string).
        destinationCode:
          type: string
          nullable: true
          description: Primary destination code (first in closestDestinations).
        closestDestinations:
          type: array
          items:
            $ref: '#/components/schemas/ClosestDestination'
          description: Tourist destinations near the hotel (city, region); always present, empty when none.
        ranking:
          type: number
          format: float
          nullable: true
          description: |
            Internal quality composite (0-100) used for ordering (blends location, reviews,
            value and popularity). Present when a valuation exists; uses neutral defaults for
            missing signals, so it is NOT a guest-review score. For reviews use reviewScore.
        reviewScore:
          type: number
          format: float
          nullable: true
          description: |
            Guest review score (0-100) from REAL external reviews only. Null/absent when the hotel
            has no reviews yet ("absent" = "not rated"); never a synthetic default. For an
            always-present quality signal for ordering, use `ranking` instead.
        reviewCount:
          type: integer
          nullable: true
          description: Number of real guest reviews backing reviewScore. Null/absent when the hotel is not rated.
        createdAt:
          type: string
          format: date-time
          nullable: true
        updatedAt:
          type: string
          format: date-time
          nullable: true
        city:
          type: string
          nullable: true
          description: City name from address; also in location.city.
        countryCode:
          type: string
          nullable: true
          description: Canonical country ISO 3166-1 alpha-2 (from geo enrichment).
        countryName:
          type: string
          description: Human-readable country name when countryCode is set; empty when unknown.
        canonicalCityPlaceId:
          type: string
          nullable: true
          description: places.id (UUID) of canonical city from geo enrichment.
        cityGeonameId:
          type: string
          nullable: true
          description: GeoNames ID of canonical city when available.
        distanceToCenterKm:
          type: number
          format: float
          nullable: true
          description: Distance from hotel to city center in km.
        region:
          type: string
          nullable: true
          description: Canonical region name (e.g. Costa Brava) when city has REGION parent.
        regionPlaceId:
          type: string
          nullable: true
          description: places.id (UUID) of region place.
        regionGeonameId:
          type: string
          nullable: true
          description: GeoNames ID of region when available.
        cityTimezone:
          type: string
          nullable: true
          description: IANA timezone of canonical city (e.g. Europe/Madrid).
        distanceToBeachKm:
          type: number
          format: float
          nullable: true
        distanceToAirportKm:
          type: number
          format: float
          nullable: true
        distanceToTrainStationKm:
          type: number
          format: float
          nullable: true
        nearestAirportIata:
          type: string
          nullable: true
        nearestBeachPlaceId:
          type: string
          nullable: true
          description: places.id of nearest beach.
        nearestAirportPlaceId:
          type: string
          nullable: true
        nearestTrainStationPlaceId:
          type: string
          nullable: true
        cityPois:
          type: array
          items:
            $ref: '#/components/schemas/HotelCityPoi'
          description: POIs near canonical city center (airports, etc.).
        locationPath:
          type: string
          nullable: true
          description: Hierarchical breadcrumb (e.g. Madrid, Community of Madrid, Spain) from places.path.
        externalIds:
          $ref: '#/components/schemas/HotelExternalIds'
          nullable: true
          description: Cross-reference ids for agency mapping (provider property ids, Bundleport base code).

    HotelExternalIds:
      type: object
      properties:
        bundleportBaseCode:
          type: string
          nullable: true
          description: Bundleport canonical hotel base code.
        providerPropertyIds:
          type: object
          nullable: true
          additionalProperties:
            type: string
          description: >
            Provider-native property ids keyed by content source code (e.g. {"example": "129046618"}).
            Provider-agnostic cross-reference map for mapping against an external property
            reference; any source may appear.
          example:
            example: "129046618"

    ClosestDestination:
      type: object
      description: Tourist destination near the hotel (from hotel_closest_destinations).
      required:
        - destinationCode
      properties:
        destinationCode:
          type: string
          description: Stable ID (place ID or provider code) for programs.
        destinationName:
          type: string
          description: Human-readable name; empty when missing or not displayable.

    HotelCityPoi:
      type: object
      description: POI near the hotel canonical city (e.g. airport, point of interest).
      properties:
        placeId:
          type: string
        type:
          type: string
          description: POI, AIRPORT
        name:
          type: string
        distanceKm:
          type: number
          format: float
          description: Distance from city center in km.
        iata:
          type: string
          nullable: true

    HotelDescription:
      type: object
      properties:
        text:
          type: string
          description: Description text content
        language:
          type: string
          description: ISO 639-1 language code

    MandatoryFee:
      type: object
      description: Mandatory fee or charge
      properties:
        name:
          type: string
          description: Fee name
        text:
          type: string
          description: Fee description
        price:
          type: number
          format: float
          description: Fee amount
        included:
          type: boolean
          description: Whether the fee is included in the price

    RoomData:
      type: object
      description: Room data as part of hotel information
      properties:
        code:
          type: string
          description: Room code
        roomCode:
          type: string
          description: Provider-specific room code
        maxOccupancy:
          type: integer
          description: Maximum occupancy stated by the provider.
        bundleportRoomCode:
          type: string
          nullable: true
          description: Canonical room code from mapping_rooms when mapped.
        contentSource:
          type: string
          nullable: true
          description: >
            Provider code that supplied images/texts/beds when they were inherited via
            cross-provider fill (same hotel base + same bundleportRoomCode). Omitted when
            content comes from the room's own provider.
        texts:
          type: array
          items:
            $ref: '#/components/schemas/Text'
          description: Room descriptions (when includeRoomDetail is true).
        images:
          type: array
          items:
            $ref: '#/components/schemas/RoomImage'
          description: Room images (when includeRoomDetail is true).
        beds:
          type: array
          items:
            $ref: '#/components/schemas/Bed'
          description: Bed information (when includeRoomDetail is true).

    Amenity:
      type: object
      description: Hotel amenity information
      required:
        - code
      properties:
        code:
          type: string
          description: Amenity code identifier
        name:
          type: string
          nullable: true
          description: Amenity name
        nameEn:
          type: string
          nullable: true
          description: Amenity name in English
        type:
          type: string
          nullable: true
          description: Amenity type/category
        description:
          type: string
          nullable: true
          description: Amenity description
        category:
          type: string
          nullable: true
          description: Canonical category for display/filtering (e.g. Pool, Gym, Room amenity)
        subcategory:
          type: string
          nullable: true
          description: Canonical subcategory for display/filtering (e.g. Pool, Wellness, Dining, RoomEquipment)

    Text:
      type: object
      properties:
        type:
          type: string
          description: Type of text (e.g., description, shortDescription)
        languageCode:
          type: string
          description: ISO 639-1 language code
        text:
          type: string
          description: Text content

    Media:
      type: object
      description: >
        Hotel image. Only the URL and its position are available: no provider supplies
        dimensions, captions or a media type at hotel level, so `width`, `height`, `caption`
        and `mediaType` were removed from this schema rather than left documented and always
        empty. Room images (`RoomImage`) do carry `mediaType` and `caption`.
      required:
        - url
      properties:
        url:
          type: string
          description: Absolute media URL.
        orderIndex:
          type: integer
          nullable: true
          description: Display order as delivered by the provider.
        isPrimary:
          type: boolean
          nullable: true
          description: True for the first image of the hotel.

    Location:
      type: object
      description: Hotel location and address (from provider address). Use root-level countryName/countryCode for canonical geo.
      properties:
        address:
          type: string
          nullable: true
          description: Primary address line.
        line2:
          type: string
          nullable: true
          description: Secondary address line (backward compatibility).
        city:
          type: string
          nullable: true
          description: City name from address.
        state:
          type: string
          nullable: true
          description: State or province.
        countryCode:
          type: string
          nullable: true
          description: ISO 3166-1 alpha-2 from address.
        countryName:
          type: string
          description: Human-readable country name when countryCode is set; empty when unknown.
        postalCode:
          type: string
          nullable: true
          description: Postal or ZIP code.
        latitude:
          type: number
          format: float
          nullable: true
          description: Latitude coordinate.
        longitude:
          type: number
          format: float
          nullable: true
          description: Longitude coordinate.

    Contact:
      type: object
      description: Hotel contact information
      properties:
        phone:
          type: string
          nullable: true
          description: Phone number
        email:
          type: string
          nullable: true
          description: Email address
        website:
          type: string
          nullable: true
          description: Website URL
        fax:
          type: string
          nullable: true
          description: Fax number

    AuditData:
      type: object
      properties:
        processTime:
          type: integer
          description: Processing time in milliseconds

    Error:
      type: object
      properties:
        code:
          type: string
        type:
          type: string
          enum: [CLIENT, SERVER]
        description:
          type: string

    Warning:
      type: object
      properties:
        code:
          type: string
        type:
          type: string
        description:
          type: string

    MappingQuality:
      type: object
      description: >
        Base code fragmentation: groups of identifiers that still describe the same physical
        hotel, plus the state of the review queue that resolves them.
      properties:
        fragmentedGroups:
          type: integer
          description: Groups of base codes detected as the same hotel.
        baseCodesInGroups:
          type: integer
        redundantBaseCodes:
          type: integer
          description: Identifiers that would disappear once every group is merged.
        hotelsAffected:
          type: integer
        pendingReview:
          type: integer
        confirmedForMerge:
          type: integer
        mergedGroups:
          type: integer
        rejectedGroups:
          type: integer

    ErrorResponse:
      type: object
      properties:
        errors:
          type: array
          items:
            $ref: '#/components/schemas/Error'

    DestinationsRequest:
      type: object
      properties:
        query:
          $ref: '#/components/schemas/DestinationListQuery'
        token:
          type: string
          description: >
            Pagination token for the next page of results.
            To retrieve the next page, include this token in the 'token' field of the subsequent request.
            Tokens are valid for 4 minutes and are tied to the original query criteria.
            If the query criteria change, a new token must be generated.

    DestinationListQuery:
      type: object
      properties:
        connectionCode:
          type: string
          description: Optional connection code for filtering
        providerCode:
          type: string
        destinationCodes:
          type: array
          items:
            type: string
        maxSize:
          type: integer

    DestinationsResponse:
      type: object
      properties:
        destinations:
          $ref: '#/components/schemas/DestinationConnection'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/Error'

    DestinationConnection:
      type: object
      properties:
        destinations:
          type: array
          items:
            $ref: '#/components/schemas/Destination'
        count:
          type: integer
        token:
          type: string

    Destination:
      type: object
      description: Destination information with flattened structure
      properties:
        code:
          type: string
          description: Unique destination identifier
        destinationCode:
          type: string
          description: Destination code
        type:
          type: string
          description: Destination type (e.g., city, region, country)
        name:
          type: string
          description: Destination name
        available:
          type: boolean
          description: Whether the destination is available
        destinationLeaf:
          type: array
          items:
            type: string
          description: Child destination codes
        closestDestinations:
          type: array
          items:
            type: string
          description: Nearby destination codes
        parent:
          type: string
          description: Parent destination code
        texts:
          type: array
          items:
            $ref: '#/components/schemas/Text'
          description: Destination texts in different languages
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    BoardsRequest:
      type: object
      properties:
        query:
          $ref: '#/components/schemas/BoardQuery'

    BoardQuery:
      type: object
      properties:
        connectionCode:
          type: string
          description: Optional connection code for filtering
        providerCode:
          type: string
        boardCodes:
          type: array
          items:
            type: string

    BoardsResponse:
      type: object
      properties:
        boards:
          $ref: '#/components/schemas/BoardConnection'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/Error'

    BoardConnection:
      type: object
      properties:
        boards:
          type: array
          items:
            $ref: '#/components/schemas/Board'
        count:
          type: integer
        token:
          type: string
          description: >
            Continuation token emitted when more boards exist than the server page size.
            BoardsRequest has no `token` or `maxSize` field today — clients cannot page
            by echoing this value. Prefer filtering with `query.connectionCode` /
            `query.boardCodes` so the result fits in one response.

    Board:
      type: object
      properties:
        code:
          type: string
        boardData:
          $ref: '#/components/schemas/BoardData'

    BoardData:
      type: object
      properties:
        code:
          type: string
        boardCode:
          type: string

    CategoriesRequest:
      type: object
      properties:
        query:
          $ref: '#/components/schemas/CategoryQuery'

    CategoryQuery:
      type: object
      properties:
        connectionCode:
          type: string
          description: Optional connection code for filtering
        providerCode:
          type: string
        categoryCodes:
          type: array
          items:
            type: string

    CategoriesResponse:
      type: object
      properties:
        categories:
          $ref: '#/components/schemas/CategoryConnection'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/Error'

    CategoryConnection:
      type: object
      properties:
        categories:
          type: array
          items:
            $ref: '#/components/schemas/Category'
        count:
          type: integer
        token:
          type: string
          description: >
            Continuation token emitted when more categories exist than the server page size.
            CategoriesRequest has no `token` or `maxSize` field today — clients cannot page
            by echoing this value. Prefer filtering with `query.connectionCode` /
            `query.categoryCodes` so the result fits in one response.

    Category:
      type: object
      description: Category information with flattened structure matching CategoryData
      properties:
        categoryCode:
          type: string
          description: Category code identifier
        description:
          type: string
          description: Category description
        language:
          type: string
          description: Language code for the description
        code:
          type: string
          description: Alias for categoryCode (for compatibility)
        texts:
          type: array
          items:
            $ref: '#/components/schemas/Text'
          description: Category texts in different languages
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    RoomsRequest:
      type: object
      required:
        - query
      properties:
        query:
          $ref: '#/components/schemas/RoomQuery'
        token:
          type: string
          description: >
            Pagination token from a previous response. It encodes the offset and a hash of
            the search criteria, so it is only valid for the exact same `query`.

    RoomQuery:
      type: object
      description: >
        At least one of `connectionCode`, `providerCode`, `roomCodes`, `roomRefs` or
        `hotelCodes` is required. Without a scope the request is rejected with 400.
        Do not combine `roomRefs` with flat `roomCodes` (ambiguous).
      anyOf:
        - required: [connectionCode]
        - required: [providerCode]
        - required: [roomCodes]
        - required: [roomRefs]
        - required: [hotelCodes]
      properties:
        connectionCode:
          type: string
          description: >
            Connection code in the form `{orgCode}-{providerCode}-{id}`. Resolves to a
            provider and scopes the query. A code that does not resolve returns 400.
        providerCode:
          type: string
        roomCodes:
          type: array
          items:
            type: string
        roomRefs:
          type: array
          description: >
            Multi-provider room lookup by (providerCode, roomCodes) pairs. Each entry
            scopes its room codes to that provider. Mutually exclusive with flat
            `roomCodes`.
          items:
            $ref: '#/components/schemas/RoomRef'
        hotelCodes:
          type: array
          items:
            type: string
          description: >
            Provider hotel codes to scope the query. Also required for cross-provider
            room content fill (hotel-base scope); without it fill is skipped.
        language:
          type: string
          example: es
          description: >-
            Language tag for the returned room texts. Fallback chain: exact tag, same base
            language, English, then every language we hold. Omit it to receive all languages.
        maxSize:
          type: integer

    RoomRef:
      type: object
      required:
        - providerCode
        - roomCodes
      properties:
        providerCode:
          type: string
          description: Provider that owns the room codes
        roomCodes:
          type: array
          items:
            type: string
          description: Provider-specific room codes for this provider
    RoomsResponse:
      type: object
      properties:
        rooms:
          $ref: '#/components/schemas/RoomConnection'
        warnings:
          type: array
          description: >
            Non-fatal hints about how the request was served.
            `WARN_SANDBOX_SHARED_CATALOG` means the sandbox provider has no catalog of its own
            and results come from the shared multi-provider catalog, so they are not scoped to
            that connection. `WARN_PARAM_IGNORED` names query fields this endpoint does not
            implement (for example `offset`, `roomName` or `scope`) and which therefore had no
            effect: paginate with `token` and filter with `roomCodes` or `hotelCodes`.
          items:
            $ref: '#/components/schemas/Warning'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/Error'

    RoomConnection:
      type: object
      properties:
        rooms:
          type: array
          items:
            $ref: '#/components/schemas/Room'
        count:
          type: integer
        token:
          type: string

    Room:
      type: object
      description: Room information from /v1/rooms endpoint
      properties:
        code:
          type: string
          description: Room code
        roomData:
          $ref: '#/components/schemas/RoomDataFull'
          description: Complete room data
        createdAt:
          type: string
          format: date-time
          description: Record creation timestamp
        updatedAt:
          type: string
          format: date-time
          description: Record last update timestamp

    RoomDataFull:
      type: object
      description: Complete room data from /v1/rooms endpoint
      properties:
        code:
          type: string
          description: Room code
        roomCode:
          type: string
          description: Provider-specific room code
        source:
          type: string
          description: Room source/provider
        maxOccupancy:
          type: integer
          description: >
            Maximum occupancy when the provider stated it. Note that no provider currently
            populates it, so it is absent in practice.
        texts:
          type: array
          items:
            $ref: '#/components/schemas/Text'
          description: Room descriptions in different languages
        images:
          type: array
          items:
            $ref: '#/components/schemas/RoomImage'
          description: Room images
        occupancy:
          $ref: '#/components/schemas/Occupancy'
          description: Occupancy details
        beds:
          type: array
          items:
            $ref: '#/components/schemas/Bed'
          description: Bed information
        bundleportRoomCode:
          type: string
          nullable: true
          description: Canonical room code from mapping_rooms when mapped.
        bundleportRoomCodeConfidencePct:
          type: integer
          minimum: 0
          maximum: 100
          nullable: true
          description: >
            How much `bundleportRoomCode` is worth. 0 means the canonical code is literally the
            provider's own code and no matching happened, so it groups nothing; 100 means a
            human reviewed it. Omitted when the mapping predates the confidence overlay, which
            should be read as unknown rather than as zero.
        bundleportRoomCodeSource:
          type: string
          nullable: true
          enum: [mirror, auto_passthrough, matcher, manual]
          description: >
            Where the mapping came from. `mirror` is the mapping generator, `auto_passthrough`
            is a placeholder with no matching behind it, `matcher` is an automatic room match
            and `manual` a human decision.
        contentSource:
          type: string
          nullable: true
          description: >
            Provider code that supplied images/texts/beds when they were inherited via
            cross-provider fill (same hotel base + same bundleportRoomCode). Omitted when
            content comes from the room's own provider.

    RoomImage:
      type: object
      description: Room image/media
      properties:
        code:
          type: string
          description: Image code
        url:
          type: string
          description: Image URL
        order:
          type: integer
          description: Display order
        mediaType:
          type: string
          description: Type of media
        caption:
          type: string
          description: Image caption

    Occupancy:
      type: object
      description: Room occupancy information
      properties:
        adults:
          type: integer
          description: Number of adults
        children:
          type: integer
          description: Number of children
        total:
          type: integer
          description: Total occupancy

    Bed:
      type: object
      description: Bed information
      properties:
        type:
          type: string
          description: Bed type
        count:
          type: integer
          description: Number of beds

    MetadataRequest:
      type: object
      properties:
        query:
          $ref: '#/components/schemas/MetadataQuery'

    ScopeAuditRequest:
      type: object
      required:
        - contentType
        - contentID
      properties:
        contentType:
          type: string
          enum: [hotel, destination, board, category, room, metadata]
          description: Type of content
        contentID:
          type: string
          description: ID of the content
        providerCode:
          type: string
          description: Optional provider code filter
        connectionCode:
          type: string
          description: Optional connection code filter
        limit:
          type: integer
          default: 50
          description: Maximum number of audit records to return

    ScopeAuditResponse:
      type: object
      properties:
        auditRecords:
          type: array
          items:
            $ref: '#/components/schemas/ScopeAuditRecord'
        count:
          type: integer

    ScopeAuditRecord:
      type: object
      properties:
        auditID:
          type: integer
          format: int64
        scopeID:
          type: integer
          format: int64
          nullable: true
        contentType:
          type: string
        contentID:
          type: string
        providerCode:
          type: string
        connectionCode:
          type: string
          nullable: true
        action:
          type: string
          enum: [created, updated, deleted, enabled, disabled]
        oldValues:
          type: object
          additionalProperties: true
          nullable: true
        newValues:
          type: object
          additionalProperties: true
          nullable: true
        changedBy:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time

    MetadataQuery:
      type: object
      properties:
        providerCodes:
          type: array
          items:
            type: string
          description: Provider codes to filter metadata
        providerCode:
          type: string
          description: Optional provider code for filtering
        connectionCode:
          type: string
          description: Optional connection code for filtering
        maxSize:
          type: integer
          description: Maximum number of results to return

    MetadataResponse:
      type: object
      properties:
        metadata:
          $ref: '#/components/schemas/MetadataConnection'
        errors:
          type: array
          items:
            $ref: '#/components/schemas/Error'

    MetadataConnection:
      type: object
      properties:
        metadata:
          type: array
          items:
            $ref: '#/components/schemas/Metadata'
        count:
          type: integer
        token:
          type: string
          description: >
            Pagination token for the next page of results.
            To retrieve the next page, include this token in the 'token' field of the subsequent request.
            Tokens are valid for 4 minutes and are tied to the original query criteria.
            If the query criteria change, a new token must be generated.

    Metadata:
      type: object
      properties:
        code:
          type: string
        metadataData:
          $ref: '#/components/schemas/MetadataData'

    MetadataData:
      type: object
      properties:
        providerCode:
          type: string
          description: Provider code for this metadata
        content:
          type: object
          additionalProperties: true

    ScopeStatsRequest:
      type: object
      required:
        - connectionCode
      properties:
        connectionCode:
          type: string
          description: Connection code to filter statistics
        providerCode:
          type: string
          description: Optional provider code filter
        contentType:
          type: string
          enum: [hotel, destination, board, category, room, metadata]
          description: Optional content type filter
        startDate:
          type: string
          format: date
          description: Optional start date (YYYY-MM-DD)
        endDate:
          type: string
          format: date
          description: Optional end date (YYYY-MM-DD)
        limit:
          type: integer
          default: 100
          description: Maximum number of records to return

    ScopeStatsResponse:
      type: object
      properties:
        stats:
          type: array
          items:
            $ref: '#/components/schemas/ScopeStatRecord'
        count:
          type: integer

    ScopeStatRecord:
      type: object
      properties:
        statID:
          type: integer
          format: int64
        connectionCode:
          type: string
        providerCode:
          type: string
        contentType:
          type: string
        dateBucket:
          type: string
          format: date
          description: Date bucket (YYYY-MM-DD)
        createdCount:
          type: integer
          description: Number of items created on this date
        updatedCount:
          type: integer
          description: Number of items updated on this date
        deletedCount:
          type: integer
          description: Number of items deleted on this date
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

