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 |
| 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:
user:friends:{id} (10-min cache)
user:location: + presence: + active_location: (3N keys)
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¶
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