Skip to content

Locations — Domain Spec

A location is a real-world place with a coordinate, a multi-language name, and a provider-backed identity. Every persisted Location is backed by a real Photon or Google Places match — there are no manual placeholder rows. This page is the rules for resolution, dedup, and provider escalation.

Mental model

Two shapes:

  1. Photon-primary resolution. Photon (free, fast, OSM-backed) is the synchronous source. Google Places is called only when the caller hints Provider=google_places. Rich-detail enrichment of Photon rows happens later in a background worker.
  2. Two-layer dedup at write time. Provider-ID composite unique index (with JSONB containment fallback on secondary_providers to catch merged rows); then proximity (per-category radius) + name fuzzy for the long tail. Anything that slips through both is resolved by the admin merge endpoint.

Provider attribution

Every row's source provider is recorded in provider + provider_id:

provider provider_id Affordance
photon {osm_type}:{osm_id} (e.g. N:12345) Name, address, category, OSM tags
google_places Google Place ID (e.g. ChIJabcd…) Hours, photos, ratings, business status — fully enriched at create time

Photon rows in rich-detail buckets (food, drinks, accommodation, …) later pick up Google enrichment via the background worker; the Google place_id lands in secondary_providers.

Resolution order (Resolve)

  1. If the request carries (provider, provider_id) → Redis cache, then DB lookup on (provider, provider_id) OR JSONB containment on secondary_providers. Hit → return.
  2. Else proximity + name lookup within 50 m → DB hit, return.
  3. Else if Provider=google_places → Google DetailsByID / NearbySearch; match → create row, return.
  4. Else Photon ReverseGeo at the coords; match → create row, return.
  5. No provider match → return (nil, nil). No row is created. Callers (moments, events, /resolve endpoint) treat nil as "place isn't in our world" — the caller keeps coords on its own row + free-text label.

Read paths

Endpoint Behaviour
GET /locations/nearby DB + Photon nearby merged, deduped, sorted by Haversine, capped at 10
GET /locations/autocomplete DB ILIKE search first, topped up from Photon
GET /locations/reverse Reverse-geocodes coordinates to a city via Photon
POST /locations/resolve Resolve a LocationCandidate into a persisted Location

Refresh (user-reported staleness)

A small "Report outdated info" trigger on the location detail view lets a user signal that a place's name, hours, address, etc. don't look right. The UX frames this as a report — the user submits and gets a "thanks" confirmation. No live update is shown and the user can't tell whether a refresh actually fired.

Underneath, POST /locations/{id}/report-outdated enqueues a background refresh task. The worker re-pulls the row's canonical provider (Photon for provider=photon rows, Google for provider=google_places rows) and, for rich-detail categories, also re-runs Google enrichment. The 7-day EnrichmentAt guard suppresses the actual refresh if the row was already refreshed recently — so repeated reports collapse silently. No periodic sweeps exist; refresh is purely event-driven (creation + user reports).

Localizations

Property Value
Languages indexed 28 (SupportedLocalizationLanguages in location_service.go)
When written At Resolve time, via parallel Photon calls
Storage locations.localizations JSONB
Read-side trimming Trimmed to caller's BCP 47 fallback chain via FilterLocalizationsForLang

Invariants

Invariant Why
Every persisted row is provider-backed Photon or Google; no manual placeholder rows
Photon-first, Google on demand Photon is free and unlimited; Google bill is bounded
Photon outages must not break user flows PhotonService swallows all errors and returns empty slices
Google Places is rate-limited per user places:ratelimit:{userID}, 10/min
Refresh is suppressed if EnrichmentAt < 7 days ago Bounds Google spend and protects against repeated user reports without needing a separate rate limiter
Proximity dedup radius is per-category Fast nearby-location dedup via the PostGIS geography index
OSM-source dedup uses osm_id exactly Two Photon results with the same OSM ID never appear twice
Cross-source dedup uses 50 m + name overlap Same restaurant from both Google and Photon shown once
Localization list must match the DevOps indexer If the language list shrinks, the photon-indexer LANGUAGES env must shrink in lockstep

Lifecycle (prose)

(none) → created → (refreshed-on-user-report)* → (soft-deleted on admin merge).

  • Created once, on the first Resolve that successfully matches a provider for that candidate. Photon-rooted rows additionally enqueue a one-shot Google enrichment if their tags are rich-detail.
  • Refreshed only when a user reports outdated info via POST /locations/{id}/report-outdated AND EnrichmentAt is older than 7 days. The row keeps its (provider, provider_id) identity across refreshes; only the descriptive fields change.
  • Soft-deleted when an admin folds the row into a survivor via POST /admin/locations/merge. MergedInto points at the survivor; the row remains as a resolvable redirect. Soft-delete is reserved for moderation / takedown — there's a deleted_at column but no scheduled purge job. Locations are inherently public; they don't disappear because they're not used, they only disappear because they're removed.

Cross-domain interactions

Other domain Interaction
Events Events have an optional location_id; the location surfaces in event detail
Moments Moments may attach to a location via location_id; standalone moments use raw coordinates
Discovery Locations are first-class map markers; moment-count badges roll up onto the location pin
Identity (none) — locations are not user-owned

Privacy / visibility

Locations are inherently public (real-world venues). Privacy lives on the things attached to a location:

Object on a location Privacy controlled by
Moments The moment's own visibility (public / friends_only)
Events The event's own visibility (public / friends / invite_only)
User-reported pin The user who reported it (no claim of ownership)

See also