openapi: 3.0.3
info:
  title: GeosPlAf API
  description: |
    Self-improving place search & mapping platform for Africa.
    Ships with 54,000+ pre-loaded Cameroon places from OpenStreetMap,
    covering settlements, POIs, junctions, and 589 administrative boundaries.

    **Typo-tolerant fuzzy search** with pg_trgm (0.1 threshold), Levenshtein
    edit distance, ILIKE fallback for short queries, and database-backed
    synonym expansion (st->saint, texaco->MRS, etc.).

    **Field mask** support on all endpoints via `?fields=` parameter to
    reduce payload size for slow networks.

    ## Authentication
    All `/api/*` endpoints require an API key passed in the `X-API-Key` header.
    Keys can optionally be restricted to specific origins (domains).
    Request an API key from your GeosPlAf admin.

    ## Multi-Source Search
    Search merges three data sources with contributor data boosted over base OSM data:
    - **Contributor points** (highest boost) -- validated by the community
    - **Aliases** (medium boost) -- alternative names and corrections
    - **OSM base** (baseline) -- 54,000+ imported Cameroon places
  version: 2.0.0
  contact:
    name: Vimex Coursier
servers:
  - url: https://geosplaf.vimex-sarl.com
    description: Production
  - url: https://sandbox.geosplaf.vimex-sarl.com
    description: Sandbox
  - url: http://localhost:8088
    description: Local (via Nginx)
  - url: http://localhost:8000
    description: Local (Direct)

tags:
  - name: Search
    description: Search-as-you-type autocomplete, suggestions, nearby
  - name: Geocode
    description: Forward and reverse geocoding
  - name: Places
    description: Place detail with hierarchy and nearby
  - name: Contributor
    description: Submit and validate points
  - name: System
    description: Health and status

components:
  securitySchemes:
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        API key for authentication. Keys can optionally be restricted to
        specific origins via the admin dashboard.

        **Base URLs:** Select your server from the dropdown above:
        - Production: `https://geosplaf.vimex-sarl.com`
        - Local (Nginx): `http://localhost:8088`
        - Direct: `http://localhost:8000`

  schemas:
    Place:
      type: object
      properties:
        id: { type: string, description: Unique identifier }
        gid: { type: string, description: Globally Unique Identifier (source:layer:id) }
        name: { type: string }
        display_name: { type: string, description: Formatted address (most local parent first) }
        lat: { type: number, format: double }
        lon: { type: number, format: double }
        rank_score: { type: number, description: 0.0-1.0 ranking }
        validation_count: { type: integer }
        source: { type: string, enum: [contributor, osm_base, alias] }
        place_type: { type: string, description: "e.g. amenity:fuel, place:village" }
        category: { type: string, description: OSM category (amenity, shop, place, etc.) }
        type: { type: string, description: OSM type value (fuel, restaurant, village, etc.) }
        osm_type: { type: string, description: "N (node), W (way), or R (relation)" }
        osm_id: { type: string, description: Original OSM numeric ID }
        boundingbox: { type: array, items: { type: number }, description: "[minlat, maxlat, minlon, maxlon]" }
        importance: { type: number, description: 0.0-1.0 importance score }
        place_rank: { type: integer, description: Nominatim-style rank (4=country, 16=city, 26=POI) }
        address: { type: object, description: Structured address components }
        namedetails: { type: object, description: Alternative names and translations }
        extratags: { type: object, description: Additional OSM metadata }
        licence: { type: string, description: OSM data attribution }

    AutocompleteResponse:
      type: object
      properties:
        results:
          type: array
          items: { $ref: '#/components/schemas/Place' }
        query: { type: string }
        took_ms: { type: integer }

    PlaceDetail:
      type: object
      properties:
        id: { type: string }
        gid: { type: string }
        name: { type: string }
        display_name: { type: string }
        place_type: { type: string }
        category: { type: string }
        type: { type: string }
        osm_type: { type: string }
        osm_id: { type: string }
        lat: { type: number }
        lon: { type: number }
        source: { type: string }
        country_code: { type: string }
        admin_level: { type: integer }
        boundingbox: { type: array, items: { type: number } }
        importance: { type: number }
        place_rank: { type: integer }
        address: { type: object }
        namedetails: { type: object }
        extratags: { type: object }
        licence: { type: string }
        tags: { type: object }
        parents:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              place_type: { type: string }
              distance_m: { type: number }
              direction: { type: string }
              method: { type: string }
        nearby:
          type: array
          items:
            type: object
            properties:
              name: { type: string }
              place_type: { type: string }
              distance_m: { type: number }
              bearing_deg: { type: number }
              direction: { type: string }

    CreatePointRequest:
      type: object
      required: [name, lat, lon]
      properties:
        name: { type: string, minLength: 2, maxLength: 255 }
        lat: { type: number, minimum: -90, maximum: 90 }
        lon: { type: number, minimum: -180, maximum: 180 }
        accuracy_meters: { type: integer, minimum: 0, maximum: 1000 }
        city: { type: string }
        language_code: { type: string }

    ValidationRequest:
      type: object
      required: [point_id, action, lat, lon, reported_name]
      properties:
        point_id: { type: string, format: uuid }
        action: { type: string, enum: [confirm, correct, reject, flag] }
        lat: { type: number }
        lon: { type: number }
        reported_name: { type: string }
        accuracy_meters: { type: integer }
        corrected_name: { type: string }
        corrected_lat: { type: number }
        corrected_lon: { type: number }

    GeocodeRequest:
      type: object
      required: [address]
      properties:
        address: { type: string }
        country: { type: string, default: CM }

    Error:
      type: object
      properties:
        error: { type: string }

  parameters:
    FieldMask:
      name: fields
      in: query
      schema: { type: string }
      description: |
        Comma-separated field names to include in response (like Google's FieldMask).
        Reduces payload size for slow networks.
        Example: ?fields=name,lat,lon,display_name
    SessionToken:
      name: sessionToken
      in: query
      schema: { type: string }
      description: Session token for grouping related requests (affects cache key)
    ExcludePlaceTypes:
      name: excludePlaceTypes
      in: query
      schema: { type: string }
      description: |
        Comma-separated place types to exclude from results.
        Example: ?excludePlaceTypes=shop:fuel,amenity:charging_station
    ViewportSWLat:
      name: sw_lat
      in: query
      schema: { type: number }
      description: Viewport bounding box southwest latitude (with sw_lon, ne_lat, ne_lon)
      example: 3.8
    ViewportSWLon:
      name: sw_lon
      in: query
      schema: { type: number }
      description: Viewport bounding box southwest longitude
      example: 11.4
    ViewportNELat:
      name: ne_lat
      in: query
      schema: { type: number }
      description: Viewport bounding box northeast latitude
      example: 3.9
    ViewportNELon:
      name: ne_lon
      in: query
      schema: { type: number }
      description: Viewport bounding box northeast longitude
      example: 11.6

security:
  - ApiKeyAuth: []

paths:
  /health:
    get:
      tags: [System]
      summary: Health check
      security: []
      responses:
        '200':
          description: Service is healthy
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  service: { type: string }

  /ready:
    get:
      tags: [System]
      summary: Readiness check
      security: []
      responses:
        '200':
          description: Service is ready

  /api/search/autocomplete:
    get:
      tags: [Search]
      summary: Search-as-you-type autocomplete
      description: |
        Typo-tolerant fuzzy search across contributor points, aliases, and OSM base data.
        Combines pg_trgm (0.1 threshold) with Levenshtein edit distance and ILIKE fallback
        for short queries. Supports synonym expansion (e.g., texaco->MRS).

        Results are ranked by relevance, location proximity (Gaussian decay, sigma=10km),
        and contributor validation score. Blacklisted GIDs are excluded.

        **Field mask**: Use `?fields=name,lat,lon,display_name` to reduce payload.

        **Viewport restriction**: Use `sw_lat, sw_lon, ne_lat, ne_lon` for DB-level
        bbox filtering (ST_MakeEnvelope on GIST index).
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
          description: Search query (partial input, typo-tolerant)
          example: "carrefour biyem"
        - name: lat
          in: query
          schema: { type: number }
          description: Latitude for location biasing
          example: 3.848
        - name: lon
          in: query
          schema: { type: number }
          description: Longitude for location biasing
          example: 11.502
        - name: country
          in: query
          schema: { type: string, default: CM }
          description: 2-char ISO country code (case-insensitive)
        - name: limit
          in: query
          schema: { type: integer, default: 10, maximum: 50 }
        - $ref: '#/components/parameters/FieldMask'
        - $ref: '#/components/parameters/SessionToken'
        - $ref: '#/components/parameters/ExcludePlaceTypes'
        - $ref: '#/components/parameters/ViewportSWLat'
        - $ref: '#/components/parameters/ViewportSWLon'
        - $ref: '#/components/parameters/ViewportNELat'
        - $ref: '#/components/parameters/ViewportNELon'
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema: { $ref: '#/components/schemas/AutocompleteResponse' }
        '401':
          description: Missing or invalid API key
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '403':
          description: Origin not allowed for this API key

  /api/search/suggest:
    get:
      tags: [Search]
      summary: Lightweight name suggestions
      parameters:
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: country
          in: query
          schema: { type: string, default: CM }
        - name: limit
          in: query
          schema: { type: integer, default: 5 }
        - $ref: '#/components/parameters/FieldMask'
      responses:
        '200':
          description: Name suggestions
          content:
            application/json:
              schema:
                type: object
                properties:
                  suggestions:
                    type: array
                    items: { type: string }
                  query: { type: string }

  /api/search/nearby:
    get:
      tags: [Search]
      summary: Find points near a GPS coordinate
      parameters:
        - name: lat
          in: query
          required: true
          schema: { type: number }
        - name: lon
          in: query
          required: true
          schema: { type: number }
        - name: radius
          in: query
          schema: { type: number, default: 500 }
          description: Search radius in meters
        - name: limit
          in: query
          schema: { type: integer, default: 20 }
        - $ref: '#/components/parameters/FieldMask'
      responses:
        '200':
          description: Nearby points
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items: { $ref: '#/components/schemas/Place' }

  /api/geocode/reverse:
    get:
      tags: [Geocode]
      summary: GPS coordinates to nearest place
      parameters:
        - name: lat
          in: query
          required: true
          schema: { type: number }
        - name: lon
          in: query
          required: true
          schema: { type: number }
        - name: radius
          in: query
          schema: { type: number, default: 500 }
        - $ref: '#/components/parameters/FieldMask'
      responses:
        '200':
          description: Nearest place or fallback

  /api/geocode/structured:
    post:
      tags: [Geocode]
      summary: Address string to coordinates
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/GeocodeRequest' }
      responses:
        '200':
          description: Geocoded place
        '404':
          description: Address not found

  /api/places/{id}:
    get:
      tags: [Places]
      summary: Full place detail with parent hierarchy and 360deg nearby
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
          description: Place source_id or GID
        - $ref: '#/components/parameters/FieldMask'
        - $ref: '#/components/parameters/SessionToken'
      responses:
        '200':
          description: Full place detail
          content:
            application/json:
              schema: { $ref: '#/components/schemas/PlaceDetail' }
        '404':
          description: Place not found

  /api/contributor/points:
    post:
      tags: [Contributor]
      summary: Submit a new place point
      description: |
        Creates a new contributor point with automatic duplicate detection.
        If a point with the same normalized name exists within 500m, the existing
        point is returned instead (deduplication).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreatePointRequest' }
      responses:
        '201':
          description: Point created
        '200':
          description: Duplicate point returned (deduplication)

  /api/contributor/points/{id}:
    get:
      tags: [Contributor]
      summary: Get point by ID
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
      responses:
        '200':
          description: Point details
        '404':
          description: Point not found

  /api/contributor/validate:
    post:
      tags: [Contributor]
      summary: Validate a point at a location
      description: |
        The core self-improving loop. Contributors confirm, correct, or reject
        points from their GPS location. Each action affects the point's rank score
        and the contributor's reputation.

        - **confirm**: Updates position (weighted Bayesian average), increments rank
        - **correct**: Creates alias with corrected name, slightly penalizes rank
        - **reject**: Decrements rank, flags as disputed if drops below 0.1
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/ValidationRequest' }
      responses:
        '200':
          description: Validation recorded, rank recomputation queued

  /api/contributor/me:
    get:
      tags: [Contributor]
      summary: Get current contributor profile
      responses:
        '200':
          description: Contributor profile with reputation
