Presence¶
Purpose¶
PresenceService answers two related but separate questions:
- Is the user online right now? — a short TTL'd Redis key (
presence:{userID}) that the client refreshes with a heartbeat. - Is the user actively sharing live location? — a typed session with one of four allowed intervals (30 / 60 / 360 / 720 minutes) backed by both a Redis key (
active_location:{userID}) and a row inactive_location_sessionsfor crash recovery.
It is intentionally tiny and Redis-first; the persistence layer is a fallback for restart scenarios. The Friends and Discovery services consume the keys it writes (via MGET) to decorate friend markers.
Responsibilities¶
UpdatePresence(userID)— setpresence:{userID}to"1"with a 3-minute TTL, async-touchusers.last_active_atStartActiveLocation(ctx, userID, intervalMinutes)— validate the interval, write a JSON session blob to Redis with matching TTL, deactivate previous DB rows, and create a newActiveLocationSessionStopActiveLocation(ctx, userID)— delete the Redis key and mark all DB rows inactiveGetActiveLocationStatus(ctx, userID)— Redis-first read, falls back toactive_location_sessionsand restores the Redis key from the DB row's remaining TTL when there's a cache miss
HTTP endpoints¶
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/v1/presence/heartbeat |
JWT | Refresh online TTL (no DB write on hot path) |
| POST | /api/v1/presence/active/start |
JWT | Start an active-location session — body { "interval_minutes": 30 \| 60 \| 360 \| 720 } |
| POST | /api/v1/presence/active/stop |
JWT | Stop the current session immediately |
| GET | /api/v1/presence/active/status |
JWT | Return { is_active, interval_minutes, started_at, expires_at } |
Key types¶
type PresenceService struct {
activeLocationRepo repository.ActiveLocationRepository
userRepo repository.UserRepository
redisService RedisService
}
// Allowed intervals — anything else is rejected.
var validIntervals = map[int]bool{30: true, 60: true, 360: true, 720: true}
// models.ActiveLocationSession
type ActiveLocationSession struct {
ID uuid.UUID
UserID uuid.UUID
IntervalMinutes int // 30, 60, 360, 720
StartedAt time.Time
ExpiresAt time.Time
IsActive bool
}
Data model¶
active_location_sessions— one active row per user (DeactivateAllis called before each newCreate). Used as a restart-safe fallback when the Redis key has expired or been evicted.- Redis keys (no DB):
presence:{userID}—"1", TTL 3 minutesactive_location:{userID}— JSON{interval_minutes, started_at, expires_at}, TTL matches the session interval
Dependencies¶
RedisServicerepository.ActiveLocationRepository—Create,FindActive,DeactivateAllrepository.UserRepository— asyncTouchLastActiveAt
Notable behavior¶
Why both Redis and Postgres?
Redis handles the hot path: WebSocket presence checks, friend feeds, and discovery marker decoration all MGET against presence:* and active_location:*. Postgres is the recovery store — if the Redis instance is wiped, GetActiveLocationStatus rehydrates the key from active_location_sessions so the user doesn't need to retap "Share live location" after a redeploy.
Heartbeat TTL = 3 minutes
The client must heartbeat at least every 3 minutes or the user shows offline. Going offline is implicit (TTL expiry) — there is no presence/stop endpoint.
Activity-status setting gates the IsOnline flag
presence:{userID} existing alone isn't enough for the discovery / friends feed to show the user as online. The consumer also checks User.ChatPreferences.ActivityStatus; users who have disabled "show activity status" appear offline even when their presence TTL is fresh.
Where to look¶
backend/internal/services/presence_service.gobackend/internal/handlers/presence_handler.gobackend/internal/repository/active_location_repository.gobackend/internal/models/active_location.go- Consumers:
backend/internal/services/friend_service.go(GetFriendLocations),backend/internal/services/discovery_service.go(appendFriendMarkers,GetRadarData)