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 (
GetFriendshipStatusesfor a list of target IDs) - Maintain the per-owner close-friends list backing the
close_friendsaudience - Update the caller's location in Redis (privacy-gated, with stay-duration preservation)
- Compose the
GetFriendLocationsfeed from the friend list plus presence and active-location signals - Notify the graph counterparties and emit
friend:graph_changedover 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'sFriendGraph, which now carries aBlockedset (RepoLoaderfolds inBlockedCounterpartIDs).CanViewdenies 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.NotificationInputcarries 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:
user:friends:{id} (10-min cache)
user:location: + presence: + active_location: (3N keys)
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.
GetLiveActivityservesstats:live:v1(45s TTL) and rebuilds on miss. The handler addsCache-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¶
friendships—id,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
UserStore—IsLocationSharedvia the profile, friend-list page enrichment platform/cache— friend-list cache, location store, presence + active-location readsplatform/notify— friend request/accept notifications andfriend:graph_changedclient eventsCloseFriendStore+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
SendRequestnotifies the recipient (friend_request_received) and emitsfriend:graph_changedto both parties.AcceptRequestnotifies the original requester (friend_request_accepted), resolves the recipient's pending request notification, and emitsfriend:graph_changedto both.RejectRequest(decline or cancel) deletes the recipient's pending request notification and emitsfriend:graph_changedto both.
See notifications for the channel matrix.
Where to look¶
backend/internal/services/friend/friend_service.go— graph, location feed, notifier emitsbackend/internal/services/friend/close_friend_service.go,close_friend_store.go— inner-circle listbackend/internal/services/friend/friend_handler.go,close_friend_handler.go— HTTP surfacebackend/internal/services/friend/friend_store.go,routes.go— persistence + route registrationbackend/internal/models/friendship.go,close_friend.go— the row shapes