Skip to content

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/taxonomy ParseQuery: free-text to (category, tags, freetext).
  • platform/llm semantic resolver, reached transitively through location.LocationService.Search, which runs the search enricher over Photon rows. See Semantic Resolver.
  • location.LocationService geocode: 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.

GetDiscoveryData
bbox + zoom
↓ branch on zoom
tier strategy
zoom ≤4
grid clusters, 10° cells
zoom 5-7
grid clusters, 3° cells
zoom 8-10
markers 200/200/100, sponsor filter
zoom 11-13
markers 500/300/150
zoom ≥14
markers 1000/500/200
↓ every tier
friend markers
IsPriority=true, bypass clusters
DiscoveryPayload
Marker caps are 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.

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).

  1. If city is set, forward-geocode it via LocationService.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's lat/lng when Photon cannot resolve the string.
  2. Run taxonomy.ParseQuery against both the place and event vocabularies and merge the tag sets.
  3. Fan out concurrently to LocationService.Search and EventService.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 declares applies_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