Skip to content

Discovery

Purpose

DiscoveryService is the read-only aggregate that powers the map, the radar, the public profile, and the detail panels for events / locations / moments. It speaks PostGIS for spatial queries and Redis (via RedisService) for the live friend overlay. RecommendationService is its scoring sidekick — it ranks events by a simple category-affinity + distance-decay model and is invoked by EventService.GetEvents rather than by handlers directly.

The two services together implement the viewport tier strategy that keeps the map fast at any zoom level (continent-wide cluster aggregates at low zoom, individual markers at high zoom).

Responsibilities

DiscoveryService

  • GetDiscoveryData — bounding-box + zoom-tier map data. Tiers 0-1 (zoom ≤7) return grid-aggregated geo_cluster markers; tiers 2-4 return individual events / locations / moments
  • GetRadarData — friends + Near-You strangers sorted by distance, both filtered by Haversine radius
  • GetLocationDetail — location metadata, top moment highlights (friends-first), upcoming events at that location
  • GetEventDetail — full event payload with visibility / invite-only enforcement; chat_room_id only returned to joined participants
  • GetMomentDetail — full moment with privacy + expiry enforcement; like state for the requester
  • GetUserProfile — public profile with mutual-friend counts, recent moments, upcoming + past events. Works for unauthenticated requesters (degraded view)
  • SearchUsers — name/username ILIKE search with batch friendship-status lookup and optional mutual-friend enrichment

RecommendationService

  • RankEvents(userID, events, params) — orders an event slice by categoryAffinity * 10 + distanceDecay * 20. Returns events unchanged on profile-build error so it never breaks the listing.

HTTP endpoints

Method Path Auth Description
GET /api/v1/discovery/map JWT Viewport map data (min_lat, max_lat, min_lng, max_lng, zoom)
GET /api/v1/discovery/radar JWT Nearby users (lat, lng, radius in metres)
GET /api/v1/discovery/location/:id JWT Location detail
GET /api/v1/discovery/event/:id JWT Event detail
GET /api/v1/discovery/moment/:id JWT Moment detail
GET /api/v1/discovery/user/:id optional Public user profile (auth optional)
GET /api/v1/profiles/:id none Same as above, public alias for sharing
GET /api/v1/users/:userId JWT Same handler, behind auth
GET /api/v1/users/search JWT User search (q, showMutual)

Key types

type DiscoveryService struct {
    db  *gorm.DB
    rdb RedisService
}

type RecommendationParams struct {
    Location      *models.Spatial
    MaxDistanceKM float64
}

type RecommendationService interface {
    RankEvents(ctx context.Context, userID string, events []models.Event, params RecommendationParams) ([]models.Event, error)
}

type UserProfile struct {
    CategoryAffinity map[string]float64 // keyed by event category
}

Zoom-tier strategy

flowchart TD
    A[GetDiscoveryData] --> B{zoom}
    B -->|<=4 Global| C[Grid clusters, 10° cells]
    B -->|5-7 Regional| D[Grid clusters, 3° cells]
    B -->|8-10 City| E[Individual markers, 200/200/100 caps, sponsor filter]
    B -->|11-13 Nbhd| F[Individual markers, 500/300/150 caps]
    B -->|>=14 Street| G[Individual markers, 1000/500/200 caps]
    C --> H[appendFriendMarkers]
    D --> H
    E --> H
    F --> H
    G --> H
    H --> I[DiscoveryPayload]

At tiers 0-1 a single UNION SQL query snaps every coordinate to a grid cell, groups by cell, and returns one cluster marker per cell with event / location / moment counts. The frontend renders these directly without re-clustering. At tiers 2-4 the frontend's Supercluster library handles visual grouping; the backend sends raw rows.

Friend markers are always individual IsPriority=true markers at every zoom level — they bypass the cluster path so the user can always see exactly where friends are.

Data model

  • eventscoordinates (PostGIS), status, sponsorship_tier, visibility, location_id, host_id
  • locationscoordinates (PostGIS), category, moment_count (joined), average_rating, photos / hours JSON, soft-delete via deleted_at
  • momentscoordinates (PostGIS), visibility, expires_at, is_journaled, location_id, likes_count, media_url
  • friendships — read directly to enumerate friends and compute mutuals
  • event_participants — to flag the user's own events and compute participation state

Dependencies

  • *gorm.DB — raw ST_Intersects / ST_X / ST_Y PostGIS queries
  • RedisServiceMGet against user:location:, presence:, active_location: for friend / stranger overlays
  • repository.EventRepository (via RecommendationService) — user's participation history for category-affinity profile
  • utils.TimezoneFromCoords (from backend/internal/utils/) — applied to event payloads for client-side rendering

Notable behavior

Spatial uses raw SQL with ST_X/ST_Y

PostGIS geometries are scanned back as floats through ST_X(coordinates) AS lng, ST_Y(coordinates) AS lat. Don't try to map the geometry column directly — GORM cannot decode the binary representation and will silently drop rows.

Visibility / privacy enforcement

  • GetEventDetail rejects invite-only events unless the requester has an approved participant row
  • GetMomentDetail rejects friends_only moments from non-friends
  • chat_room_id on an event detail is only echoed back to joined participants / hosts
  • friends_only moments / events appear on a user's profile only if the requester is an accepted friend

Sponsor priority

Events with sponsorship_tier = 1 (global) bypass Supercluster on the frontend by being marked IsPriority=true. At city zoom (tiers ≤10), events with sponsorship_tier > 2 are filtered out so the global view doesn't drown sparse viewports with hyper-local sponsors. The user's own hosted / joined events are also IsPriority=true regardless of sponsorship.

H3 indexing

H3 cells are computed by LocationService at write time (github.com/uber/h3-go/v4, resolution 12 ≈ 5m radius) and stored on locations.h3_index. Discovery doesn't recompute them — it relies on them for fast nearby-location dedup when a moment or event is being created or resolved.

Where to look