Skip to content

Locations

Purpose

The location stack is a three-tier lookup pipeline that converts a user's tap-on-the-map (or text query) into a persisted, enriched Location row. It composes three services:

  1. LocationService — the orchestrator. Merges DB results with Photon (OSM) results, deduplicates, ranks by Haversine distance.
  2. PhotonService — self-hosted Photon (OSM) wrapper. Cheap, fast, no rate limit. Forward (/api) and reverse (/reverse) geocoding.
  3. PlacesService — Google Places API wrapper. Higher quality (ratings, hours, photos, business status) but rate-limited per user (places:ratelimit:{userID}, 10/min) and only invoked on Resolve when the location needs enrichment.

Handlers never call Photon or Google directly — they go through LocationService, which picks the right tier.

Responsibilities

LocationService

  • Nearby(lat, lng, radius, lang) — merge DB + Photon nearby results, dedup by osm_id + proximity/name, sort by Haversine distance, cap at 10
  • Autocomplete(query, lat, lng, limit, lang) — DB ILIKE search first, top up from Photon search
  • Resolve(candidate) — convert a candidate into a persisted Location. Order:
    1. If candidate has a DB ID and is fresh (<30 days) → return as-is
    2. If candidate has an osm_id or google_place_id → look up by it (fresh check applies)
    3. Proximity + name lookup within 50 m
    4. Call Google Places Nearby Search (radius 100 m) for enrichment; create or refresh DB row
    5. Fall through to a minimal record with verification_status = user_reported / photon_matched
  • ReverseGeocodeCity(lat, lng, lang) — return the city name at coords via Photon's parent-place tags
  • buildLocalizations(ctx, lat, lng) — fan out Photon calls across 28 supported languages in parallel, prune entries that duplicate the English baseline, store the result in locations.localizations (JSONB)

PhotonService

  • Hit a configured Photon URL with ?lang= normalised to Photon's expected codes (e.g. ja-JPja, zh-CNzh-Hans)
  • Map OSM osm_key/osm_value pairs to simplified Tomoda categories (restaurant→food, gym→fitness, etc.)
  • Drop nameless features in Search/ReverseGeocode; keep them in ReverseGeocodeCity because city tags live on those rows

PlacesService

  • Use H3 (github.com/uber/h3-go/v4) at resolution 12 (~5 m cell) for fast nearby-location dedup
  • Call Google Places v1 with a tight field mask to minimise per-request cost
  • Rate-limit per user before any Places call (places:ratelimit:{userID})
  • Map Google response → models.Location (MapPlaceToLocation), including localized JSON for hours / photos / types
  • Provide a fallback minimal Location if Google fails or is rate-limited

HTTP endpoints

Method Path Auth Description
GET /api/v1/locations/nearby JWT DB + Photon nearby (lat, lng, optional radius in metres, lang)
GET /api/v1/locations/autocomplete JWT Text query autocomplete (q, lat, lng, limit, lang)
GET /api/v1/locations/reverse JWT Reverse-geocode coords to a city via Photon (lat, lng, lang)
POST /api/v1/locations/resolve JWT Resolve a LocationCandidate into a persisted Location (body)

The handler picks lang from ?lang=Accept-Language"en" (see resolveLang in location_handler.go).

Key types

type LocationCandidate struct {
    ID            string  // present if source == "db"
    Name          string
    Localizations map[string]models.LocationTranslation // filtered to caller's fallback chain
    Address       string
    Latitude, Longitude float64
    Category      string
    Source        string  // "db" | "photon" | (Google handled internally)
    GooglePlaceID string
    OsmID         int64
    OsmType       string  // "N" | "W" | "R"
    City, State, Country, CountryCode string
    PlaceType     string  // raw OSM value
    Distance      float64 // metres, filled by LocationService
}

type LocationService interface {
    Nearby(ctx context.Context, lat, lng, radius float64, lang string) ([]LocationCandidate, error)
    Autocomplete(ctx context.Context, query string, lat, lng float64, limit int, lang string) ([]LocationCandidate, error)
    Resolve(ctx context.Context, candidate LocationCandidate) (*models.Location, error)
    ReverseGeocodeCity(ctx context.Context, lat, lng float64, lang string) (string, error)
}

Resolve flow

flowchart TD
    A[Candidate] --> B{DB ID set?}
    B -->|yes, fresh| Z[Return DB row]
    B -->|no/stale| C{osm_id set?}
    C -->|yes, fresh DB hit| Z
    C -->|no/stale| D{google_place_id?}
    D -->|fresh DB hit| Z
    D -->|no/stale| E[Proximity + name lookup]
    E -->|fresh DB hit| Z
    E -->|miss| F[Google Places NearbySearch radius=100m]
    F -->|success| G[MapPlaceToLocation → Create/Update Location]
    G --> Z
    F -->|fail/empty| H{Existing stale row?}
    H -->|yes| Z
    H -->|no| I[createMinimalLocation: user_reported/photon_matched]
    I --> Z

Data model

  • locationsname, address, full_address, category, sub_category, verification_status (google_verified / photon_matched / user_reported), business_status, coordinates (PostGIS), h3_index, google_place_id (nullable), osm_id / osm_type, localizations (JSONB), photos, opening_hours, place_types (all JSON strings), phone_number, international_phone_number, website, google_maps_uri, average_rating, total_ratings, price_level, country, country_code, last_activity_at, deleted_at

Dependencies

  • repository.LocationRepositorySearchNearby, SearchByName, GetByID, GetByOsmID, GetByGooglePlaceID, GetByH3Index, FindByProximity, Create, Update
  • RedisServiceRateLimit for Google Places (places:ratelimit:{userID})
  • External: self-hosted Photon HTTP API, Google Places v1 API

Notable behavior

Photon-first, Google on demand

Photon is queried freely (no rate limit, fast, self-hosted). Google Places is only called from Resolve when the location either has no DB record or has gone stale (>30 days). The pattern keeps the Google bill bounded while still giving every persisted location the full enrichment (ratings, hours, photos) eventually.

28-language localizations

Every persisted location carries translations for the 28 languages listed in SupportedLocalizationLanguages (location_service.go). They are written once at Resolve time via parallel Photon calls — wall-clock cost is one Photon RTT. The translation list mirrors the photon-indexer DevOps job — if you shrink the list, shrink the indexer's LANGUAGES env too. On every read path the response is trimmed to the caller's BCP 47 fallback chain via FilterLocalizationsForLang to keep payloads small (~90 % size reduction).

Photon never returns errors

PhotonService.doRequest and ReverseGeocodeCity deliberately swallow all errors and return empty slices/strings. Photon outages must not break user flows — Google or the minimal-record path picks up the slack.

OSM ID + proximity dedup

deduplicatePhoton removes Photon results whose osm_id matches a DB row. For DB rows that came from Google (no osm_id), it falls back to "within 50 m and name overlap" — so the same restaurant in both sources is shown once.

Where to look