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, max 50000)
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/feed JWT SearchHandler.Feed Home feed: full-width moments + ranked events (pill toggle)
POST /discovery/feed/seen JWT SearchHandler.MarkSeen Mark moments seen ({moment_ids}) as they scroll past
GET /discovery/search/events JWT SearchHandler.ListEvents Event search near the pin (optional start_after/start_before RFC3339 date bounds)
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
GET /discovery/profiles/:id/moments optional GetUserMoments Keyset-paginated profile moments tab
GET /discovery/profiles/:id/journal optional GetUserJournal Auto-grouped passport journal (trips / days / singles), keyset-paginated
GET /discovery/profiles/:id/atlas optional GetAtlasIndex Passport atlas country index
GET /discovery/profiles/:id/atlas/:country optional GetCountryPage Atlas country drill (regions / cities / places)
GET /discovery/profiles/:id/atlas/:country/regions optional GetCountryRegions Visited admin-1 regions within one country, with GeoJSON boundaries

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.

Per-viewer visibility. Every content marker is scoped to what the caller may actually see, so the map never leaks restricted content:

  • Moments (standalone pins + a location's moment badge) apply the moment's own visibility: public to anyone, friends to accepted friends, close_friends only to the author's close friends (closeFriendViewers), private never. Standalone moment pins are additionally friend-authored only, gated by the ShowFriendMoments preference, and bounded to a recency window (standaloneMomentMaxAgeHours, 48h) so an old moment doesn't linger as a stale "someone's here now" pin.
  • Events mirror the feed's visibility (public + friend-hosted friends events) plus the viewer's own hosted/joined events, which always show. Events tied to a location ride that location's pin (count + gold overlay); only location-less events get a standalone pin, so nothing shows twice.

Density-adaptive locations. A location normally needs activity (a visible moment or upcoming event) to appear, ranked friend-activity-first and capped per zoom. When a viewport is sparse (fewer than sparseLocThreshold active locations, e.g. a quiet or just-launched city), ListNotablePOIMarkers backfills rating-ranked nearby POIs up to sparseLocTarget so the map still shows what's around. Dense viewports clear the threshold and skip the backfill entirely, so they stay curated and pay for no extra query. All map queries are pre-filtered by the PostGIS ST_Intersects index, so the visibility/recency predicates only run over the viewport candidate set.

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.

Radius ceiling and read-time sharing check

The candidate query is not spatially filtered, so radius is capped at 50km (maxRadarRadiusMeters in handler.go) and anything larger is a 400, along with a lat/lng outside [-90,90] / [-180,180]. Every position is also re-checked against the target's is_location_shared before it is returned: the cached user:location:{id} entry carries its own TTL, so key presence alone is not permission to reveal a position.

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

Event ranking (event_rank.go) runs on the shared ranking spine (internal/platform/ranking): a broad candidate window is scored by a normalized 0..1 blend of graded title match (spine name tiers), category, tag overlap, and gaussian proximity, plus personalization — category affinity from the viewer's UserContext and social proof (how many friends are going, log-saturated). The social term counts a close friend twice (once as a friend, once as a close friend), so a close friend going outweighs a regular friend, mirroring the moments-feed tiers. Friend/participant counts are computed once over the window and reused for the display fields. Nil-safe, so anonymous ranks on intent + proximity only. /discovery/search/locations delegates to location.Search (which is also personalized); Discovery does not re-rank locations.

Home feed (feed.go, GET /discovery/feed) returns two sections the client renders below a pinned brand header. A hero (greeting + map preview) sits on top with the feed peeking below it; scrolling down snaps the feed to center under a sticky, centered Moments/Events tab selector (content starts below the tabs). Each item fills the height between the header and the bottom nav, one at a time, swipe up/down for both: a moment shows exactly like the moment viewer, an event shows as a cover card (its cover art, or a generative category cover when none is set). Tapping the active tab jumps back to the hero. The moments pool is retrieved from social sources — the viewer's own moments, close friends, friends, bounded 2-hop friends-of-friends (public only), and moments a friend is tagged in — deduped to the strongest source tier, scored on the spine (social-proof, recency, popularity, affinity, proximity), capped by a per-author diversity limit, then ordered unseen-first, each group newest-first. Every moment carries a seen flag, a location_id (for the save-to-plan action), and a per-viewer user_liked flag (an index-backed EXISTS on moment_likes in the feed queries) so the heart renders correctly across sessions. The moment tray is cursor-paginated: the first request ranks the full eligible set, freezes that order (id + tier + seen) in Redis (feed:snap:{uid}, 15m TTL), and returns a feedPageSize window plus a next_cursor; later requests (?cursor=) re-project the next window from the frozen order, so pages stay stable even as the seen-set advances mid-scroll. Events are bounded and returned on the first page only. The events section reuses ListEvents with an empty query (browse-mode ranking), then drops events the viewer hosts or has joined (discovery shows what's new; the viewer's own events live in their events view). Search stays comprehensive. Each card carries a "why" (e.g. "Jo posted" or "Your moment"). Anonymous/degraded reads are nil-safe. FoF is capped + public-only; the feed includes the viewer's own moments (shared-album feel).

Seen-behavior. The feed leads with moments the viewer has not seen, so a refresh surfaces fresh content instead of replaying; when the unseen run ends it keeps going into already-seen moments chronologically (the client marks the "all caught up" seam at the first seen card). Unseen cards are selected before the cap, so a fresh moment is never crowded out. The seen-set is a Redis set owned by discovery (feed:seen:{uid}, a JSON blob via SetCache, 14-day TTL, bounded to 500 ids) — independent of the activity firehose and its sampling/retention. The client POSTs /discovery/feed/seen with moment ids as each scrolls into view; the read (seenMomentSet) partitions the feed. Anonymous or empty reads fall back to plain newest-first.

Saving a search result to the capture Stash is a plan write: POST /items/save-location (see Plans), not a Discovery route. The location detail payload carries user_saved, computed via the SavedPlaceReader port onto the stash store: true when the requester holds an active place-card for the location, false for anonymous callers.

The location detail payload also carries collections, the curated-collection prestige badges (Michelin stars plus award year, top-best rank), built from CollectionsForLocations via distinctionFromMembership, the same shape the search list uses. The award year comes from the membership's distinction.year, which can differ per region from the collection's edition.

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.

Personalization and category affinity

Every personalized ranking (location search, event search, home feed) reads a per-viewer UserContext from internal/services/personalization: category affinity scores (0..1, 0.5 neutral), saved and dismissed location ids, and rolled-up interaction edges. It is cache-first (Redis blob, short TTL) and nil-safe, so an anonymous or degraded read ranks on intent and proximity alone.

Category affinity is the taste profile the recommender leans on. Scores live in user_affinity(user_id, category, score), owned by the maps domain. Two things move them:

  • Bumps (immediacy). A meaningful action nudges the acted-on category up, its reversal nudges it down (BumpAffinityUp = +0.05 capped at 1.0, BumpAffinityDown = -0.03 floored at 0.0). Saves and dismissals bump inside the save transaction (maps/service.go, affinityTx); other domains bump through the shared BumpCategoryAffinity write path.
  • Reconcile (correctness). A lazy reconcile corrects the drift bumps accumulate. maps.ReconcileAffinity recomputes the profile from the last ~60 days of actions, replacing the scores; each action lifts its category 0.05 above the 0.5 neutral baseline (saturating at 1.0). It is triggered from personalization.Build on a cache miss (roughly the context TTL cadence per user), runs fire-and-forget on a detached context, and self-throttles on a Redis watermark (affinity:reconciled:{uid}, 7-day TTL: key present means fresh). A dormant window (no recent actions) keeps the existing profile and just re-stamps the watermark, so a returning user is never wiped.

Adding a taste signal (the extensible seam)

maps.Service.BumpCategoryAffinity(userID, category, up) is the single write path, and it busts the viewer's cached UserContext. A domain that emits a taste signal consumes it as a one-method inbound port, so signals grow without coupling domains to the maps package. Three steps:

  1. Declare an AffinityBumper port in the domain's deps.go (one method, matching BumpCategoryAffinity). See moment/deps.go for the reference.
  2. Bind it in wiring/providers.go: wire.Bind(new(<domain>.AffinityBumper), new(*maps.Service)).
  3. Call it on the action (up=true) and its reversal (up=false) with the resolved category. A nil bumper is a no-op, so tests and seeders need not wire it.

The taste signals wired today, all through this seam:

Signal Direction Category from Site
Save / dismiss a location up / down location maps/service.go (affinityTx)
Like / un-like a moment up / down moment moment/service.go (ToggleLike)
Join / leave an event up / down event event/event_service.go (JoinEvent, LeaveEvent)
Add a place to a plan up location plan/item_service.go (ensurePlanLocation, SaveLocation)
Check in at a location up location presence/checkin_service.go (CreateCheckin)

Check-in and plan-place are up-only (their removals are weak, logistics-driven signals); the lazy reconcile is the drift control for those.

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.

The card is a passport surface, so it runs the target's passport_visibility ladder through the user domain's GetForViewer before it is assembled (ProfileVisibilityReader in service.go). Anonymous callers are measured as a viewer that can never be a friend, and a private passport answers 404 rather than confirming the account exists. See User for the ladder itself.

Journal (GET /discovery/profiles/:id/journal)

The passport's unified "what I've done" record, auto-grouped server-side. ListJournal reads the target's travel log newest-first (the geography-bearing spine, so every entry carries a country, city, and location) and attaches a payload per entry: a visible moment makes it a moment entry; a matched event_id (LEFT JOINed to events) makes it an event entry carrying an EventBrief ("attended {event}"); a bare check-in stays a visit entry. Plans and future events never appear: the travel log records only past, proven visits. The same passport_visibility ladder gates the page as the profile card; a viewer the passport is closed to gets an empty page.

segmentJournal (a pure function in service.go) groups the entries. A logical day buckets by local_time (fallback visited_at) with a pre-dawn cutoff of logicalDayCutoffHour (4): an entry before 04:00 local folds into the previous logical day, so a 23:00 dinner and a 01:00 club group together.

Kind Rule
trip A contiguous run of entries in a non-home country, bounded by a return to the home country, a country switch, or a time gap > tripGapDays (2). Title is the country; subtitle is the distinct cities.
day >= dayOutMinItems (2) entries the same logical day in one city, even at home, that aren't already inside a trip.
single An isolated entry.

Home base is the profile's HomeCountry/HomeCity, falling back to the modal country across the page; activity in the home country is never a trip. Each JournalSegment carries stops: a JournalStop is the entries at one location_id within one logical day, with its own logical_date, start/end, moment/event counts, up to four moment thumb_urls, and the EventBrief when the stop is an attended event. A trip carries all its stops (the client groups them by logical_date); a day/single carries its stop(s). The segment rolls up a date range, per-type counts (moment_count / event_count / visit_count), and a cover (the first moment image in the run). Pagination is keyset by (visited_at, travel_log_id); next_cursor is the oldest entry's cursor. A trip spanning the page boundary may split at the edge, which the client merges by the stable segment key.

Atlas region drill (GET /discovery/profiles/:id/atlas/:country/regions)

GetCountryRegions returns the viewer's visited admin-1 regions within one country (resolved via user_travel_logs.region_id joined to regions), each carrying region_id, name, iso_3166_2, depth, visit_count, and a boundary GeoJSON polygon (ST_AsGeoJSON over a server-side ST_SimplifyPreserveTopology). Only rows with a non-null boundary are returned, and the query is bounded to one country so the payload never carries all regions globally. Gated by the same passport_visibility ladder; a closed passport, or a country with no boundary-bearing visited regions, returns an empty list.

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
JournalPage / JournalSegment / JournalStop / TimelineEntry / EventBrief / TimelineVisitBrief /discovery/profiles/:id/journal
AtlasIndex / CountryPage / CountryRegions / AtlasRegionGeo /discovery/profiles/:id/atlas…

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