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, 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.
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.
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:
publicto anyone,friendsto accepted friends,close_friendsonly to the author's close friends (closeFriendViewers),privatenever. Standalone moment pins are additionally friend-authored only, gated by theShowFriendMomentspreference, 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-hostedfriendsevents) 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.
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 |
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.05capped at 1.0,BumpAffinityDown=-0.03floored at 0.0). Saves and dismissals bump inside the save transaction (maps/service.go,affinityTx); other domains bump through the sharedBumpCategoryAffinitywrite path. - Reconcile (correctness). A lazy reconcile corrects the drift bumps
accumulate.
maps.ReconcileAffinityrecomputes the profile from the last ~60 days of actions, replacing the scores; each action lifts its category0.05above the0.5neutral baseline (saturating at 1.0). It is triggered frompersonalization.Buildon 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:
- Declare an
AffinityBumperport in the domain'sdeps.go(one method, matchingBumpCategoryAffinity). Seemoment/deps.gofor the reference. - Bind it in
wiring/providers.go:wire.Bind(new(<domain>.AffinityBumper), new(*maps.Service)). - 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 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.
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¶
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 loadingbackend/internal/services/personalization/: theUserContextbuilder (affinity, saves, dismissals, edges)backend/internal/services/maps/service.go:BumpCategoryAffinity(the taste-signal write path) +affinityTx- Locations · Maps · Semantic Resolver · User