Discovery¶
Purpose¶
Discovery is the query and read-aggregation domain. It answers "what is near me / what matches this query / who is this person" by reading across events, locations, moments, friends, and presence, then shaping a single payload the client renders directly. It never owns write state of its own, it composes other domains' data.
Everything the map, radar, Find tab, and public profile card need is
mounted under /api/v1/discovery. Discovery queries Postgres directly
(raw PostGIS for spatial reads) and reuses three shared pieces rather than
re-implementing them:
platform/taxonomyParseQuery: free-text to(category, tags, freetext).platform/llmsemantic resolver, reached transitively throughlocation.LocationService.Search, which runs the search enricher over Photon rows. See Semantic Resolver.location.LocationServicegeocode: forward-geocodes a city string to a search anchor, reverse-geocodes coords to a city.
Code lives in backend/internal/services/discovery/.
HTTP surface¶
All routes are mounted in backend/internal/services/discovery/routes.go.
The profile read is registered by RegisterPublicRoutes (optional auth);
everything else by RegisterRoutes (JWT-verified group).
| Method | Path | Auth | Handler | Description |
|---|---|---|---|---|
| GET | /discovery/map |
JWT | GetDiscoveryData |
Viewport markers (min_lat, max_lat, min_lng, max_lng, zoom) |
| GET | /discovery/radar |
JWT | GetRadarData |
Nearby people (lat, lng, radius in metres) |
| GET | /discovery/locations/:id |
JWT | GetLocationDetail |
Location map-card detail |
| GET | /discovery/search |
JWT | FindByIntent |
Unified NL intent search (q, optional lat/lng/lang/city) |
| GET | /discovery/search/events |
JWT | FindHandler.ListEvents |
Event search near the pin |
| GET | /discovery/search/locations |
JWT | FindHandler.ListLocations |
Place search near the pin |
| GET | /discovery/search/users |
JWT | SearchUsers |
User search (q, showMutual) |
| GET | /discovery/pin |
JWT | FindHandler.GetFindLocation |
Read the saved area pill |
| PUT | /discovery/pin |
JWT | FindHandler.SetFindLocation |
Write the saved area pill |
| GET | /discovery/taxonomy |
JWT | TaxonomyHandler.Get |
Localized category + tag vocabulary |
| GET | /discovery/taxonomy/resolve |
JWT | TaxonomyHandler.ResolveQuery |
Free-text query to canonical name + kind |
| GET | /discovery/profiles/:id |
optional | GetUserProfile |
Full aggregated public profile card |
Lean vs full profile
GET /users/:userId (see User) returns only the lean
identity card (id, name, username, avatar_url, bio,
is_friend), enough for a mention chip. The full aggregated profile
(recent moments, upcoming/past events, mutual friends) is the
Discovery read at GET /discovery/profiles/:id. :id accepts a user
UUID or a case-insensitive username.
Tap-through map cards (location + event detail sheets, save/dismiss) are a
separate surface owned by the Maps domain, which reads the
canonical Location/Event/Moment rows. Discovery emits the viewport
marker; Maps serves the sheet the marker opens.
Map: zoom-tier strategy¶
GetDiscoveryData branches on zoom so the map stays fast from a
continent-wide view down to a single street. Tier constants and caps live
at the top of backend/internal/services/discovery/service.go.
bbox + zoom
grid clusters, 10° cells
grid clusters, 3° cells
markers 200/200/100, sponsor filter
markers 500/300/150
markers 1000/500/200
IsPriority=true, bypass clusters
events / locations / moments. Friend markers always bypass the cluster path so exact friend positions show at every zoom.At zoom ≤7 a single UNION query snaps every coordinate to a grid cell,
groups by cell, and returns one geo_cluster marker per cell carrying
event / location / moment counts in ClusterCounts. The frontend renders
these directly. At zoom ≥8 the backend sends raw rows and the frontend's
Supercluster library handles the visual grouping.
Sponsor priority
At city zoom (≤10) only events with sponsorship_tier ≤ 2 are
returned, so the wide view is not drowned by hyper-local sponsors.
Global sponsors and the caller's own hosted/joined events are marked
IsPriority=true and bypass Supercluster.
Radar¶
GetRadarData returns two Haversine-filtered lists, friends and
strangers, each a RadarUser with distance and live position. It reads
friend positions and presence from Redis via the shared cache. Radar is
the "who is around me right now" list behind the map.
Search¶
Discovery exposes one unified search and three kind-scoped searches.
Unified intent (GET /discovery/search)¶
FindByIntent (service.go) takes a natural-language sentence
("wine tasting tonight", "sushi near me") and returns matching locations
and events plus how the query was interpreted, so the client can render
"showing X near you" hints (IntentPayload, IntentInterpretation).
- If
cityis set, forward-geocode it viaLocationService.Autocomplete(Photon) and use those coords as the anchor. This powers "bars in Toronto" from Ottawa with no client-side city picker; it falls back to the caller'slat/lngwhen Photon cannot resolve the string. - Run
taxonomy.ParseQueryagainst both theplaceandeventvocabularies and merge the tag sets. - Fan out concurrently to
LocationService.SearchandEventService.Search. Either side may be empty independently; no match on locations does not suppress events. Both sides only error out when both fail.
LocationService.Search is where the platform/llm semantic resolver
participates: it enriches Photon candidates through the search enricher.
Discovery itself holds no LLM code.
Kind-scoped search (Find tab)¶
The Find tab searches events and places separately near the saved pin.
FindHandler (find_handler.go) reads client-supplied lat/lng when
present and otherwise falls back to the persisted pin.
| Path | Returns | Shape |
|---|---|---|
/discovery/search/events |
{ events: FindEvent[] } |
Cover, when-label, distance, friends-going stack |
/discovery/search/locations |
{ locations: FindLocation[] } |
Rating, open-state, distance, recent friends |
/discovery/search/users |
{ users: [...] } |
Name/username ILIKE (min 2 chars), friendship status, optional mutuals |
Saving a search result to the capture Stash is a plan write:
POST /items/save-location (see Plans), not a Discovery route.
Saved pin (GET/PUT /discovery/pin)¶
The Find tab's area selector. FindLocationState carries the pill label,
anchor lat/lng, radius (km), and a recents list. The frontend seeds it
from device location, updates it from the location picker, and every
search request carries the current area.
Taxonomy vocabulary¶
TaxonomyHandler (taxonomy_handler.go) wraps the shared
platform/taxonomy service and serves its localized vocabulary under
/discovery/taxonomy. The frontend fetches it once per locale on boot and
renders every category badge, tag pill, picker row, and search filter from
that snapshot.
| Path | Description |
|---|---|
/discovery/taxonomy |
Full vocabulary localized to lang (falls back to Accept-Language, then en). taxonomy.LocalizedTaxonomyPayload. |
/discovery/taxonomy/resolve |
Resolve one free-text q to { query, entity, canonical, kind } where kind is category, tag, or "". entity narrows to place or event. |
The vocabulary itself is a shared platform concern, loaded once at boot
from backend/data/*.json and consumed by location, event, and discovery
alike (there is no categories table). Its concept model:
- Bucket categories: top-level groupings (
food,culture,nightlife). Canonicals are lowercase. - Subcategories: leaves under a bucket (
food → cafe / restaurant). Search widens a bucket into the subcategory IN-set the place repo stores. - Facet tags: orthogonal modifiers (
wifi,outdoor-seating,vegan). Each declaresapplies_to_categories("*"means anywhere).
The rich-detail flag on a bucket (RichDetailTags()) is the only knob
that escalates a Photon match into paid Google enrichment; see
Locations → Provider policy. The optional
dedup_radius_m per category drives the location dedup radius; see
Locations → Dedup story.
| Data file | Owns |
|---|---|
backend/data/place_taxonomy.json |
Place buckets + subcategories + facet tags; rich_detail, dedup_radius_m, default_tags |
backend/data/event_taxonomy.json |
Event buckets + subcategories + tag families |
backend/data/curio_categories.json |
Curio categories with 28-locale translations |
Longest-match tokenization
ParseQuery scans CJK / scriptio-continua input character by
character, picking the longest known label at each position, and
tokenizes space-separated languages on whitespace. That is why
"ラーメン居酒屋" splits into two matches rather than one blob.
Public profile¶
GetUserProfile returns the privacy-aware UserProfilePayload: identity,
friendship_status, recent moments, mutual friends (count and, for authed
callers, the list), and upcoming + past events. Auth is optional so share
links resolve unauthenticated; an authed caller gets viewer-relative
fields. friends_only moments and events appear only when the requester
is an accepted friend.
Key types¶
Response shapes live in backend/internal/services/discovery/types.go.
Small cross-cutting briefs (UserBrief, EventBrief, MomentHighlight)
are duplicated per domain by design; the Discovery copies are its own.
| Type | Used by |
|---|---|
DiscoveryPayload / MapMarker / ClusterCounts |
/discovery/map |
RadarPayload / RadarUser |
/discovery/radar |
LocationDetailPayload / OpeningHours |
/discovery/locations/:id |
IntentPayload / IntentInterpretation / IntentLocation / IntentEvent |
/discovery/search |
FindEvent / FindLocation / FindLocationState |
Find tab search + pin (find_service.go) |
UserProfilePayload |
/discovery/profiles/:id |
Invariants and gotchas¶
Spatial reads are raw SQL
PostGIS geometries are scanned back as floats through
ST_X(coordinates) AS lng, ST_Y(coordinates) AS lat. Do not map the
geometry column directly, pgx cannot decode the geometry binary form
and silently drops rows.
Map panning returns 499, not 500
GetDiscoveryData fires on every viewport change and the client
aborts the in-flight request. That surfaces as context.Canceled;
the handler responds 499 (client closed request) so normal panning
does not show as server errors in access logs.
Opening hours are rendered from periods on read
models.Location.OpeningHours stores the structured periods only
({"periods":[...]}); the English weekday_text is intentionally not
persisted. GetLocationDetail renders the OpeningHours.weekday_text
lines Monday-first from those periods via
location.FormatWeekdayText, the same helper the search and maps
surfaces use, so all three display identical hours.
Where to look¶
backend/internal/services/discovery/service.go: map, radar, location detail, profile,FindByIntentbackend/internal/services/discovery/find_service.go: Find tab search + saved pinbackend/internal/services/discovery/handler.go,find_handler.go,taxonomy_handler.go: HTTP surfacebackend/internal/services/discovery/routes.go: route registrationbackend/internal/services/discovery/types.go: response payloadsbackend/internal/platform/taxonomy/: the shared vocabulary service + data loading- Locations · Maps · Semantic Resolver · User