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.

    **Typesense-powered typo-tolerant search.** The autocomplete engine
    tolerates 1 typo on 4-7 char queries and 2 typos on 8+ char queries
    (0 typos for ≤3 chars), with infix matching so prefixes work as you type.
    Sub-10ms p99 latency. If Typesense is unavailable, the API falls back
    to the PostgreSQL search index automatically.

    **Center bias** re-ranks results by Gaussian decay from a focus point
    (σ = 10km): equally relevant places closer to the center rank higher.

    **Geographic restriction**: a rectangular viewport via
    `sw_lat, sw_lon, ne_lat, ne_lon`, or an arbitrary polygon via
    `polygon=lon1,lat1;lon2,lat2;...` (3+ vertices, points separated by `;`,
    `lon,lat` inside a point) — both filter on the Typesense geopoint index.
    See the autocomplete endpoint for details.

    **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 priority) -- validated by the community
    - **Aliases** (medium priority) -- alternative names and corrections
    - **OSM base** (baseline) -- 54,000+ imported Cameroon places
  version: 1.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 }
        loc_name:
          type: string
          description: |
            OSM local name (loc_name) — the name people actually use, e.g.
            "Dispensaire Messassi" for "Centre Médical d'Arrondissement Yaoundé I
            Olembe". Always searchable as an alias of the place; returned only
            when present. Request it explicitly with ?fields=name,loc_name.
        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 }
        popularity_score: { type: number, description: "Demand signal, incremented when the place detail is opened (throttled); blended into 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 }
        loc_name:
          type: string
          description: OSM local name (loc_name) — the name people actually use (see Place schema)
        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 }
        popularity_score: { type: number, description: "Demand signal, blended into ranking" }
        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 }
        display_name:
          type: string
          description: Required when using an app-scoped API key — resolves the contributor identity

    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 }
        display_name:
          type: string
          description: Required when using an app-scoped API key — resolves the contributor identity

    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

        Supported on /api/search/autocomplete, /api/search/nearby,
        /api/search/suggest, /api/places/{id} and /api/geocode/reverse.
        The OSM local name is a selectable field on every one of them
        (only when the place has one): ?fields=name,loc_name.
    SessionToken:
      name: sessionToken
      in: query
      required: true
      schema:
        type: string
        maxLength: 128
        pattern: "^[A-Za-z0-9._-]{1,128}$"
      description: |
        REQUIRED on search and place endpoints. Groups requests into a session:
        the backend records the session's typed queries and joins them to the
        place detail the session eventually opens (implicit relevance feedback
        and popularity signal). Also affects the API cache key. Geocode
        endpoints do not use it.
    ExcludePlaceTypes:
      name: excludePlaceTypes
      in: query
      schema: { type: string }
      description: |
        Comma-separated place types to exclude from results.
        Example: ?excludePlaceTypes=shop:fuel,amenity:charging_station
    IncludePlaceTypes:
      name: includePlaceTypes
      in: query
      schema: { type: string }
      description: |
        Comma-separated place types to KEEP ONLY (mirrors the admin panel
        type filter). If given, results are restricted to these types.
        Example: ?includePlaceTypes=building:public,amenity:courthouse
    ViewportSWLat:
      name: sw_lat
      in: query
      schema: { type: number }
      description: Viewport southwest latitude (with sw_lon, ne_lat, ne_lon) — restricts results to a geographic zone via the Typesense geopoint polygon filter
      example: 3.8
    ViewportSWLon:
      name: sw_lon
      in: query
      schema: { type: number }
      description: Viewport southwest longitude
      example: 11.4
    ViewportNELat:
      name: ne_lat
      in: query
      schema: { type: number }
      description: Viewport northeast latitude
      example: 3.9
    ViewportNELon:
      name: ne_lon
      in: query
      schema: { type: number }
      description: Viewport northeast longitude
      example: 11.6
    ViewportPolygon:
      name: polygon
      in: query
      schema: { type: string }
      description: |
        Arbitrary polygon restriction: semicolon-separated points, each point
        a comma-separated `lon,lat` pair (longitude first), 3+ vertices,
        e.g. `?polygon=11.40,3.85;11.55,3.85;11.55,3.95;11.40,3.95`.
        The polygon is closed back to the first vertex automatically and
        matched exactly on the Typesense geopoint index (with a server-side
        containment re-filter). Mutually exclusive with
        `sw_lat/sw_lon/ne_lat/ne_lon` — when present, the polygon wins and
        the bounding-box params are ignored.

        **Parity with the admin panel**: the admin points search uses the
        exact same polygon engine — the same `q` + `polygon` (+ optional
        `lat`/`lon` center bias) returns the same result set on both.
        Session personalization is NOT applied to polygon searches (see the
        shared `SearchService.Autocomplete`), so the result order cannot
        diverge per session. Send `lon,lat` pairs — sending `lat,lon` is
        silently parsed and moves the zone to a different part of the map.
      example: 11.40,3.85;11.55,3.85;11.55,3.95;11.40,3.95

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 }
                  version: { type: string, description: "GeosPlAf API release version (1.0.0 = first stable release)" }

  /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 search across contributor points, aliases, and OSM base data,
        powered by Typesense with automatic PostgreSQL fallback.

        **Typo tolerance**: 0 typos for ≤3 char queries, 1 typo for 4-7 chars,
        2 typos for 8+ chars. Infix matching allows partial words while typing.
        Abbreviations are expanded via the native synonym store (st -> saint,
        carref -> carrefour, pharm -> pharmacie, etc.).

        **Local names (OSM loc_name)**: every place is searchable by its
        alternative names too — including the OSM `loc_name` people actually
        use on the street. `q=dispensaire messassi` returns the same place as
        `q=Centre Médical d'Arrondissement Yaoundé I Olembe` (whose loc_name is
        "Dispensaire Messassi"). This works on both the Typesense index
        (alt_names in query_by) and the PostgreSQL fallback (alt_names_text +
        search_vector).

        **Ranking**: contributor points rank above aliases, which rank above
        OSM base data; within a tier, results are ordered by validation
        `rank_score` then text relevance. Location bias re-ranks by Gaussian
        decay from `lat`/`lon` (σ = 10km). Blacklisted GIDs are excluded.

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

        **Geographic restriction** (optional, mutually exclusive):
        - **Viewport** — `sw_lat, sw_lon, ne_lat, ne_lon` for a rectangular
          viewport filter.
        - **Polygon** — `polygon=lon1,lat1;lon2,lat2;...` for an arbitrary
          polygon (points separated by `;`, `lon,lat` inside a point, 3+
          vertices). Matched exactly on the Typesense geopoint index, with a
          server-side containment re-filter so results are exact even on the
          PostgreSQL fallback. When both are supplied, the polygon wins.
      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/IncludePlaceTypes'
        - $ref: '#/components/parameters/ExcludePlaceTypes'
        - $ref: '#/components/parameters/ViewportSWLat'
        - $ref: '#/components/parameters/ViewportSWLon'
        - $ref: '#/components/parameters/ViewportNELat'
        - $ref: '#/components/parameters/ViewportNELon'
        - $ref: '#/components/parameters/ViewportPolygon'
      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
      description: |
        Resolves the identity of the API key holder. With an app-scoped key,
        returns the application plus the resolved contributor (if one exists
        for the key's display name). With a contributor-scoped key, returns
        the contributor profile and reputation.
      responses:
        '200':
          description: Application/contributor identity
          content:
            application/json:
              schema:
                type: object
                properties:
                  application_id: { type: string, format: uuid }
                  application_name: { type: string }
                  contributor_id: { type: string, format: uuid }
                  role: { type: string, description: Contributor role }
                  reputation: { type: number, description: 0.0-1.0 reputation score }
