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:
- 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. - Two-layer dedup at write time. Provider-ID composite unique
index (with JSONB containment fallback on
secondary_providersto 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)¶
- If the request carries
(provider, provider_id)→ Redis cache, then DB lookup on(provider, provider_id)OR JSONB containment onsecondary_providers. Hit → return. - Else proximity + name lookup within 50 m → DB hit, return.
- Else if
Provider=google_places→ GoogleDetailsByID/NearbySearch; match → create row, return. - Else Photon
ReverseGeoat the coords; match → create row, return. - No provider match → return
(nil, nil). No row is created. Callers (moments, events,/resolveendpoint) 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
Resolvethat 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-outdatedANDEnrichmentAtis 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.MergedIntopoints at the survivor; the row remains as a resolvable redirect. Soft-delete is reserved for moderation / takedown — there's adeleted_atcolumn 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¶
- Narrative: Bible → Locations
- Backend: Backend → Locations service
- Geocoder ops: Infrastructure → Photon
- Consumers: Domains → Events, Domains → Moments, Domains → Discovery
- UX:
../ux/locations.md(coming soon)