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

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.

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.

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}
user:privacy:{userID} 24 h is_location_shared flag ("1" / "0")

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