openapi: '3.1.0'
info:
  title: Motionworks API - Places Library
  version: 2.4.0
  description: >
    POI-anchored places library (ADR-024). One Place record per DATAPLOR
    POI; the Motionworks measured place a POI maps to is carried as
    informational `measured_*` columns rather than as the row identity.
    Multi-POI-per-measured-place is intentional signal, not a duplicate.
  contact:
    name: Motionworks AI
    url: https://mworks.com
    email: api@mworks.com

servers:
  - url: https://api.mworks.com/v2
    description: Production

security:
  - apiKey: []

components:
  securitySchemes:
    apiKey:
      type: apiKey
      name: X-API-Key
      in: header

  schemas:
    Brand:
      type: object
      description: >
        Brand-level footprint roll-up backed by the `brand_rollup`
        materialized view (ADR-024 amendment, app-mworks-com#156). v1
        keys on the lowercased brand display name (`brand_name_lower`);
        first-class `brand_id` column + chain matching are reserved for v2.
      x-motionworks-status: production
      x-motionworks-source: places-library
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      properties:
        brand_id:
          type: string
          description: >
            v1 brand identifier — the lowercased brand display name
            (`brand_rollup.brand_name_lower` PK). URL-encode reserved
            characters in the path parameter. A first-class
            non-name-derived `brand_id` is reserved for v2 (see
            app-mworks-com#147).
          example: starbucks
        name:
          type: string
          nullable: true
          description: Display form of the brand name.
        poi_count:
          type: integer
          nullable: true
          description: Total POIs in the brand footprint.
        measured_poi_count:
          type: integer
          nullable: true
          description: POIs that map to a Motionworks measured place.
        distinct_measured_places:
          type: integer
          nullable: true
          description: Distinct measured_place_id values across the brand's POIs.
        top_states:
          type: array
          items:
            type: string
          description: Up to 3 USPS state codes, ordered by POI count (desc).
        top_cities:
          type: array
          items:
            type: string
          description: Up to 3 city names, ordered by POI count (desc).
        centroid:
          type: object
          nullable: true
          description: Spatial centroid of the brand footprint.
          properties:
            lat:
              type: number
              format: double
            lon:
              type: number
              format: double
        parent_brand:
          type: string
          nullable: true
          description: >
            Reserved for v2 (parent brand / chain hierarchy). Always null
            in v1.
        chain_id:
          type: string
          nullable: true
          description: >
            Reserved for v2 (canonical chain identifier). Always null
            in v1.
        footprint_polygon:
          type: object
          nullable: true
          description: >
            Reserved for v2 (GeoJSON polygon of the brand footprint).
            Always null in v1.
        sample_poi_id:
          type: string
          nullable: true
          description: One POI id from the brand, useful for spot-checks.

    Place:
      type: object
      x-motionworks-status: production
      x-motionworks-source: places-library
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      properties:
        poi_id:
          type: string
          description: >
            Stable POI surrogate. Form `<source_prefix>_<poi_native_id>`,
            e.g. `dp_a1b2c3` for DATAPLOR. Survives monthly snapshot rollovers
            as long as the upstream source preserves its native id.
          example: dp_a1b2c3
        poi_native_id:
          type: string
          description: Upstream POI native id (e.g. DATAPLOR poi_place_id).
        poi_source:
          type: string
          description: Upstream POI source name (e.g. `dataplor`).
        name:
          type: string
          nullable: true
        name_lower:
          type: string
          nullable: true
          description: Generated lowercase form of name (for prefix lookup).
        poi_category:
          type: string
          nullable: true
        naics_category:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        st_usps:
          type: string
          minLength: 2
          maxLength: 2
          nullable: true
        st_name:
          type: string
          nullable: true
        co_geoid:
          type: string
          nullable: true
        cbsa_geoid:
          type: string
          nullable: true
        dma_geoid:
          type: string
          nullable: true
        dma_name:
          type: string
          nullable: true

        is_mapped_to_measured:
          type: boolean
          description: >
            True when this POI has been mapped to a Motionworks measured
            place. Cheap planner predicate; avoids dereferencing
            measured_place_id.
        measured_place_id:
          type: string
          nullable: true
          description: >
            Motionworks-internal measured-place id when mapped. Multiple POIs
            can share the same measured_place_id — intentional signal.
        measured_short_name:
          type: string
          nullable: true
        place_type_id_v2:
          type: integer
          nullable: true
        place_type:
          type: string
          nullable: true
        sub_type:
          type: string
          nullable: true
        measured_parent_place_id:
          type: string
          nullable: true
        measured_audit_status:
          type: string
          nullable: true
        measured_is_focused:
          type: boolean
          nullable: true
        market_id:
          type: string
          nullable: true
        poi_count_for_measured_place:
          type: integer
          nullable: true
          description: >
            Count of POIs that share this measured_place_id. > 1 surfaces the
            "multiple POIs in one measured place" measurement-quality signal.

        # match_type / match_name_score / match_dist_meters exist on the
        # places_universe table but are intentionally NOT projected to the
        # public API — they are operator-debug fields for the upstream
        # mapping pipeline. Wire them through a future /admin/ endpoint if
        # ever needed.

        lat:
          type: number
          format: double
          nullable: true
        lon:
          type: number
          format: double
          nullable: true

    AutocompleteResult:
      type: object
      description: >
        One row in the autocomplete response. Three kinds:
        `brand` (brand prefix hit from brand_rollup), `place` (POI prefix hit
        from places_universe), `brand_fuzzy` (trigram-similarity brand
        fallback when no prefix matches).
      x-motionworks-status: production
      properties:
        kind:
          type: string
          enum: [brand, place, brand_fuzzy]
        name:
          type: string
        poi_id:
          type: string
          nullable: true
          description: Populated for `kind=place`; null for brand kinds.
        place_type:
          type: string
          nullable: true
        city:
          type: string
          nullable: true
        st_usps:
          type: string
          nullable: true
        poi_count:
          type: integer
          nullable: true
          description: Populated for brand kinds; null for `kind=place`.
        match_score:
          type: number
          format: double
          nullable: true
          description: >
            For `brand_fuzzy`, the trigram similarity in [0,1]. For prefix
            kinds (`brand`, `place`), always 1.0.
        match_highlights:
          type: array
          items:
            type: object
            required: [field, start, end]
            additionalProperties: false
            properties:
              field:
                type: string
                enum: [name]
                description: >
                  The field of the autocomplete row this span refers to.
                  v1 only emits highlights for `name`.
              start:
                type: integer
                minimum: 0
                description: >
                  Inclusive JavaScript character offset into the `name`
                  field. NOT a UTF-8 byte offset — Unicode-safe across
                  accented brand names.
              end:
                type: integer
                minimum: 0
                description: >
                  Exclusive JavaScript character offset (start ≤ end ≤ name.length).
          description: >
            Matched-substring spans inside the `name` field. The UI bolds
            these character ranges. Empty array for `kind=brand_fuzzy`
            (no exact substring; client falls back to no-bold) and when
            the query does not appear inside `name`. Always present;
            never null. ADR-024 §Surfaces.

    Pagination:
      type: object
      properties:
        cursor:
          type: string
          nullable: true
          description: >
            Opaque cursor (currently the last `poi_id` in the page). Pass
            it as `?cursor=...` on the next request.
        has_more:
          type: boolean

    Provenance:
      type: object
      description: Response-level provenance (see TF-93).
      properties:
        source:
          type: string
        source_doc:
          type: string
          format: uri
        methodology_version:
          type: string
        data_vintage:
          type: string
          format: date
        data_freshness:
          type: string
          enum: [hourly, daily, weekly, monthly, annually, on-demand, static]
        data_latency_days:
          type: integer
        data_maturity:
          type: string
          enum: [production, research-preview, synthetic-only, roadmap]
        is_focused:
          type: boolean

    Meta:
      type: object
      properties:
        request_id:
          type: string
        credits_used:
          type: integer
        credits_remaining:
          type: integer
        product:
          type: string
        operation_id:
          type: string
        version:
          type: string
        provenance:
          $ref: '#/components/schemas/Provenance'

    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            status:
              type: integer
            request_id:
              type: string
            product:
              type: string
            docs_url:
              type: string

paths:
  /places/autocomplete:
    get:
      operationId: placesAutocomplete
      summary: Keystroke autocomplete (brand-first, then POI prefix, then fuzzy fallback)
      x-credit-cost: 0
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      x-motionworks-snapshot-header: X-MW-Places-Snapshot
      parameters:
        - name: q
          in: query
          required: true
          schema:
            type: string
          description: Query prefix (case-insensitive; lowercased server-side).
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 20
            default: 10
        - name: near
          in: query
          schema:
            type: string
          description: 'Optional "lon,lat" hint. v1 does not change the query plan; reserved for future spatial boost.'
      responses:
        '200':
          description: Autocomplete results
          headers:
            X-MW-Places-Snapshot:
              schema:
                type: string
                pattern: '^[0-9]{8}$'
              description: Snapshot date (YYYYMMDD) the response was served from.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/AutocompleteResult'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Internal error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /places/brands/{brand_id}:
    get:
      operationId: getPlacesBrand
      summary: Get brand footprint by `brand_id`
      x-credit-cost: 1
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      x-motionworks-snapshot-header: X-MW-Places-Snapshot
      description: >
        Returns the brand-level footprint rollup (POI count, top states +
        cities, centroid, sample POI) backed by the `brand_rollup`
        materialized view. v1 `brand_id` = `brand_name_lower` (lowercased
        display name; URL-encode reserved characters). `parent_brand`,
        `chain_id`, and `footprint_polygon` are reserved for v2 and
        always return null.
      parameters:
        - name: brand_id
          in: path
          required: true
          schema:
            type: string
          description: Lowercased brand name (e.g. `starbucks`, `taco%20bell`).
      responses:
        '200':
          description: Brand footprint
          headers:
            X-MW-Places-Snapshot:
              schema:
                type: string
                pattern: '^[0-9]{8}$'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Brand'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Invalid or missing API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Brand not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Internal error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /places/{poi_id}:
    get:
      operationId: getPlace
      summary: Get a single POI by `poi_id`
      x-credit-cost: 1
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      x-motionworks-snapshot-header: X-MW-Places-Snapshot
      parameters:
        - name: poi_id
          in: path
          required: true
          schema:
            type: string
          description: Stable POI surrogate, e.g. `dp_a1b2c3`.
      responses:
        '200':
          description: Place record
          headers:
            X-MW-Places-Snapshot:
              schema:
                type: string
                pattern: '^[0-9]{8}$'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: '#/components/schemas/Place'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '401':
          description: Invalid or missing API key
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: POI not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Rate limit exceeded

  /places:
    get:
      operationId: searchPlaces
      summary: List / filter POIs
      x-credit-cost: 1
      x-motionworks-status: production
      x-motionworks-source-doc: https://docs.mworks.com/docs/places
      x-motionworks-snapshot-header: X-MW-Places-Snapshot
      parameters:
        - name: q
          in: query
          description: >
            Weighted full-text search (uses the generated `search_tsv`
            column; `simple` dictionary preserves brand names).
          schema:
            type: string
        - name: bbox
          in: query
          schema:
            type: string
          description: 'Bounding box "w,s,e,n" (lon,lat,lon,lat) in EPSG:4326.'
        - name: near
          in: query
          schema:
            type: string
          description: 'Anchor point "lon,lat". When combined with `radius_m`, filters; also drives KNN ordering of results.'
        - name: radius_m
          in: query
          schema:
            type: number
            minimum: 0
          description: Radius in meters for `near` filter (uses `ST_DWithin` on geography).
        - name: place_type_id_v2
          in: query
          schema:
            type: integer
        - name: st_usps
          in: query
          schema:
            type: string
            minLength: 2
            maxLength: 2
        - name: dma_geoid
          in: query
          schema:
            type: string
        - name: is_mapped_to_measured
          in: query
          schema:
            type: boolean
        - name: cursor
          in: query
          schema:
            type: string
          description: Opaque cursor from a previous response's `pagination.cursor`.
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
      responses:
        '200':
          description: Paginated place records
          headers:
            X-MW-Places-Snapshot:
              schema:
                type: string
                pattern: '^[0-9]{8}$'
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Place'
                  pagination:
                    $ref: '#/components/schemas/Pagination'
                  meta:
                    $ref: '#/components/schemas/Meta'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
