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:
LocationService— the orchestrator. Merges DB results with Photon (OSM) results, deduplicates, ranks by Haversine distance.PhotonService— self-hosted Photon (OSM) wrapper. Cheap, fast, no rate limit. Forward (/api) and reverse (/reverse) geocoding.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 onResolvewhen 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 byosm_id+ proximity/name, sort by Haversine distance, cap at 10Autocomplete(query, lat, lng, limit, lang)— DB ILIKE search first, top up from Photon searchResolve(candidate)— convert a candidate into a persistedLocation. Order:- If candidate has a DB ID and is fresh (<30 days) → return as-is
- If candidate has an
osm_idorgoogle_place_id→ look up by it (fresh check applies) - Proximity + name lookup within 50 m
- Call Google Places Nearby Search (radius 100 m) for enrichment; create or refresh DB row
- 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 tagsbuildLocalizations(ctx, lat, lng)— fan out Photon calls across 28 supported languages in parallel, prune entries that duplicate the English baseline, store the result inlocations.localizations(JSONB)
PhotonService¶
- Hit a configured Photon URL with
?lang=normalised to Photon's expected codes (e.g.ja-JP→ja,zh-CN→zh-Hans) - Map OSM
osm_key/osm_valuepairs to simplified Tomoda categories (restaurant→food, gym→fitness, etc.) - Drop nameless features in
Search/ReverseGeocode; keep them inReverseGeocodeCitybecause 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
Locationif 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¶
locations—name,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.LocationRepository—SearchNearby,SearchByName,GetByID,GetByOsmID,GetByGooglePlaceID,GetByH3Index,FindByProximity,Create,UpdateRedisService—RateLimitfor 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¶
backend/internal/services/location_service.gobackend/internal/services/photon_service.gobackend/internal/services/places_service.gobackend/internal/services/country_codes.go—CountryNameToCodehelperbackend/internal/handlers/location_handler.gobackend/internal/repository/location_repository.gobackend/internal/models/location.go,spatial.go- DevOps:
devops/k8s/envs/platform/photon-indexer/— Photon planet index build pipeline