Skip to content

Friends

Purpose

The friend domain owns the friendship graph, the per-owner close-friends list, and the live location feed that depends on the graph. The friendship model is a single undirected row in friendships, with the requester in user_id_1 and the recipient in user_id_2; status is one of pending, accepted, or blocked. Two services split the work: FriendService runs the graph and the location feed, CloseFriendService runs the inner-circle list.

Responsibilities

  • Send / accept / reject / cancel a friend request (single row, status flip)
  • Unfriend (delete the row, both sides cache-invalidated)
  • List friends (paged or full), pending requests received, pending requests sent
  • Batch friendship-status lookup (GetFriendshipStatuses for a list of target IDs)
  • Maintain the per-owner close-friends list backing the close_friends audience
  • Update the caller's location in Redis (privacy-gated, with stay-duration preservation)
  • Compose the GetFriendLocations feed from the friend list plus presence and active-location signals
  • Notify the graph counterparties and emit friend:graph_changed over the client WebSocket

HTTP endpoints

Routes register through backend/internal/services/friend/routes.go and mount in backend/internal/wiring/router.go. The graph and close-friend routes sit under /friends; location sharing sits under /location (visibility is gated on the friend graph, so the friend domain owns it).

Method Path Description
POST /api/v1/friends/request Send a friend request (body: friend_id)
POST /api/v1/friends/accept Accept a pending request (body: friend_id)
POST /api/v1/friends/reject Reject or cancel a request (body: friend_id)
DELETE /api/v1/friends/:id Unfriend
POST /api/v1/friends/:id/block Block a user (any account, not just friends; removes any existing friendship or pending request; idempotent)
DELETE /api/v1/friends/:id/block Unblock (only the blocker can lift it; 400 if there is no block)
GET /api/v1/friends/blocked List the users the caller has blocked (directional; feeds the blocked-users screen)
GET /api/v1/friends List accepted friends (limit / offset for paging; returns has_more)
GET /api/v1/friends/requests Pending requests received
GET /api/v1/friends/sent Pending requests sent
GET /api/v1/friends/close List the caller's close-friend ids
POST /api/v1/friends/:id/close Mark an accepted friend as close (idempotent; 403 if the pair isn't an accepted friendship, 400 for self)
DELETE /api/v1/friends/:id/close Remove the close marking (idempotent)
POST /api/v1/location/update Update caller's location (Redis only, no DB write)
GET /api/v1/location/friends Friend live-location feed (optional latitude / longitude / radius filter)

The frontend consumes these through frontend/services/friendService.ts.

Request lists carry a brief, not a user record

GET /friends/requests and GET /friends/sent hydrate the counterparty into user_1 / user_2 as a models.UserSummary (id, name, avatar_url, username, country). Anyone may address a friend request at any account, so these fields are readable by a stranger: never widen them to models.User, which would put email, date of birth, OAuth ids, billing id, and the webhook signing secret on the wire.

Mutual-friends count

Mutual-friends data is computed by the discovery domain (aggregated profile + user search), not by the friend service. The friend service exposes only the raw graph.

Blocking

A block is a directional friendships row (user_id_1 = blocker, status = 'blocked') that any user can place on any other, not just a friend. Placing it deletes any existing friendship or pending edge first, so the pair also drops off every friend-gated surface. Only the blocker can Unblock; the blocked user is never told they were blocked (their view of the blocker's profile returns "user not found").

The friend store is the single source of truth for block state, consumed anywhere a user could otherwise reach a blocked counterpart:

  • BlockedCounterpartIDs(viewer) — every id in a block edge with the viewer, either direction. The filter set for retrieval surfaces.
  • BlockExists(a, b) — bidirectional guard for interaction paths.
  • ListBlockedUsers(blocker) — directional, for the blocked-users screen.

Two shared seams do most of the work, so a new feature inherits block-safety without wiring it by hand:

  • Reads → internal/privacy.CanView. The one visibility gate loads the viewer's FriendGraph, which now carries a Blocked set (RepoLoader folds in BlockedCounterpartIDs). CanView denies a blocked pair before any visibility rule, public content included, so every read that already flows through it (moment detail, moment likes, plans, events) is covered at once.
  • Notifications → notification.Service.Notify. NotificationInput carries both actor and recipient, so the one dispatch chokepoint drops any notification across a blocked edge. Any future notification kind is gated for free.

Beyond those two, the retrieval and interaction surfaces enforce block directly (bidirectional everywhere):

Surface How it's enforced
Visibility-gated reads privacy.CanView denies a blocked pair at any visibility (moments, moment likes, plans, events)
Notifications notification.Service.Notify drops any notification whose actor and recipient are in a block edge
People search SearchStandardUserIDs (discovery) and SearchNonFriendUsers (user) carry a NOT EXISTS block predicate keyed on the viewer
Near-You radar ListNearYouCandidateIDs carries the same predicate, so blocked strangers never enter the radar
Home feed hydrateFeedMoments drops cards authored by a blocked counterpart (the popular-nearby source can surface non-friends)
Friend suggestions FriendsOfFriends skips blocked candidates
Existing 1:1 chat guardDirectRoomBlock rejects a send when the DM counterpart is blocked (new DMs are already friend-gated)
Moment tagging Tagging requires accepted friendship, which the block deletes
Friend requests SendRequest refuses across a blocked edge
Profile card Discovery gates the passport: blocker sees an Unblock CTA, blockee gets "user not found"
Map / location feed Friend-gated, so the deleted friendship removes the pair

Key types

type Location struct {
    Lat, Lng  float64
    UpdatedAt time.Time
    ArrivedAt time.Time // preserved across updates within ~50m
}

type FriendLocationInfo struct {
    Location
    FriendID          string
    FriendName        string
    Username          string
    AvatarURL         string
    LastActiveAt      *time.Time
    IsOnline          bool
    IsActivelySharing bool
}

FriendService (backend/internal/services/friend/friend_service.go) depends on FriendStore, the user domain's UserStore, platform/cache, and platform/notify. CloseFriendService (close_friend_service.go) depends on CloseFriendStore and a FriendChecker (the accepted-friendship predicate). Friendship statuses are the models.FriendshipStatus enum (pending / accepted / blocked).

Close friends

close_friends is an owner-scoped, asymmetric join table (owner_id, friend_id): a row (alice, bob) means Alice considers Bob close; the inverse only exists if Bob added Alice too. It backs the close_friends audience on moments (see user for the shared Visibility enum). Add validates the pair has an accepted friendship (ErrCloseFriendNotFriend → 403) and rejects self-add (ErrCloseFriendSelf → 400); both Add and Remove are idempotent (ON CONFLICT DO NOTHING / delete-if-present) so the star-toggle UI can retry safely.

Location sharing

UpdateLocation writes only to Redis. It is privacy-gated: the caller's is_location_shared flag (on user_profiles, cached at user:privacy:{userID} for 24h) must be true or the write is a no-op. When the new coordinates are within ~50m of the previous point, ArrivedAt is preserved so the UI can show "been here X minutes".

GetFriendLocations builds the feed with minimal Redis round-trips:

GET /location/friends
Redis
user:friends:{id} (10-min cache)
→ miss →
friendships (ListFriends)
↓ friends with is_location_shared
single MGET
user:location: + presence: + active_location: (3N keys)
FriendLocationInfo map
One MGET per call, so the feed cost is flat regardless of friend-list size.

Live activity (anonymous)

UpdateLocation also feeds a privacy-safe aggregate that powers the public marketing globe (GET /api/v1/stats/live, unauthenticated). It runs inside the same consent gate (is_location_shared true), after the precise friend-position write, and is best-effort: an aggregation failure never fails the location update.

The aggregate stores only anonymous counts:

  • Coarse buckets. A coordinate is rounded to a 1-degree cell (~111km), so no precise point is ever kept for this feature.
  • HyperLogLog distinct counts. The user ID is fed into per-window HLLs (global and per-bucket) for distinct-user counts, then discarded. HLL retains no members, so an ID cannot be recovered.
  • k-anonymity. The read path merges the current and previous 10-minute windows, then drops any bucket with fewer than 5 distinct users, so a single person can never be geolocated from the aggregate. Results are sorted by count and capped to the busiest buckets.
  • Read-through cached. GetLiveActivity serves stats:live:v1 (45s TTL) and rebuilds on miss. The handler adds Cache-Control: public, max-age=30.

No user ID or precise coordinate is stored retrievably anywhere for this feature. See Redis keys for the stats:live:* key shapes and TTLs.

Redis keys

Key TTL Contents
user:friends:{userID} 10 min Cached lean friend list; invalidated on accept/unfriend
user:location:{userID} 24 h Last known {lat,lng,updated_at,arrived_at}; deleted when sharing is turned off
user:privacy:{userID} 24 h is_location_shared flag ("1" / "0")
stats:live:g:{w}, stats:live:b:{w}:{bucket} 25 min HLL distinct-count of active users, global and per 1-degree bucket, per 10-min window
stats:live:set:{w} 25 min Set of active buckets in a window (which HLLs to read)
stats:live:v1 45 s Read-through cache of the anonymous globe aggregate

Key presence is not permission

user:privacy only gates the write in UpdateLocation, and user:location carries a TTL of its own, so a stale position can outlive a sharing-off toggle. Readers re-check the target's is_location_shared before revealing a position.

presence:{userID} and active_location:{userID} are read (not written) here; they are owned by the presence domain.

Data model

  • friendshipsid, user_id_1 (requester), user_id_2 (recipient), status, created_at. One row per pair; sender/recipient ordering only distinguishes "sent" from "received" pending requests.
  • close_friends(owner_id, friend_id) composite PK; both FKs cascade on user delete.
  • No location data is persisted in Postgres. Live location lives entirely in Redis.

Dependencies

  • FriendStore — graph reads/writes (FindBetweenUsers, ListFriends, ListFriendsPage, ListPendingRequests, ListSentRequests, GetFriendshipsWithUsers)
  • user UserStoreIsLocationShared via the profile, friend-list page enrichment
  • platform/cache — friend-list cache, location store, presence + active-location reads
  • platform/notify — friend request/accept notifications and friend:graph_changed client events
  • CloseFriendStore + FriendChecker — the close-friends list and its accepted-friendship gate

Notable behavior

AcceptRequest takes the requester's ID, not the request ID

POST /friends/accept (and reject, and DELETE /friends/:id) takes the other user's UUID as friend_id, not the friendship row's ID. The service resolves the row via FindBetweenUsers(userID, friendID) and confirms UserID2 == userID before flipping status.

Location privacy is one-way

The caller's is_location_shared flag controls whether others see them. It does not restrict what the caller sees: GetFriendLocations always returns visible friends. Only friends whose own is_location_shared is true appear in the result.

Client events emitted

  • SendRequest notifies the recipient (friend_request_received) and emits friend:graph_changed to both parties.
  • AcceptRequest notifies the original requester (friend_request_accepted), resolves the recipient's pending request notification, and emits friend:graph_changed to both.
  • RejectRequest (decline or cancel) deletes the recipient's pending request notification and emits friend:graph_changed to both.

See notifications for the channel matrix.

Where to look