Skip to content

Presence

Purpose

PresenceService answers two related but separate questions:

  1. Is the user online right now? — a short TTL'd Redis key (presence:{userID}) that the client refreshes with a heartbeat.
  2. 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 in active_location_sessions for 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) — set presence:{userID} to "1" with a 3-minute TTL, async-touch users.last_active_at
  • StartActiveLocation(ctx, userID, intervalMinutes) — validate the interval, write a JSON session blob to Redis with matching TTL, deactivate previous DB rows, and create a new ActiveLocationSession
  • StopActiveLocation(ctx, userID) — delete the Redis key and mark all DB rows inactive
  • GetActiveLocationStatus(ctx, userID) — Redis-first read, falls back to active_location_sessions and 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 (DeactivateAll is called before each new Create). Used as a restart-safe fallback when the Redis key has expired or been evicted.
  • Redis keys (no DB):
    • presence:{userID}"1", TTL 3 minutes
    • active_location:{userID} — JSON {interval_minutes, started_at, expires_at}, TTL matches the session interval

Dependencies

  • RedisService
  • repository.ActiveLocationRepositoryCreate, FindActive, DeactivateAll
  • repository.UserRepository — async TouchLastActiveAt

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