Skip to content

Presence

Purpose

The presence domain answers "where is this user, right now and recently?" across three related surfaces:

  1. Online? — a short-TTL Redis key (presence:{userID}) the client refreshes with a heartbeat.
  2. Sharing live location? — a typed active-location session (one of four intervals) backed by Redis plus a DB row for crash recovery.
  3. Been somewhere? — check-ins: the lowest-friction visit producer, a user_checkins row plus the travel-log entries the game engine consumes.

Package presence (backend/internal/services/presence/) holds two aggregates in one slice. The realtime aggregate (presence_service.go, presence_handler.go, active_location_store.go) is Redis-first and intentionally tiny. The check-in aggregate (checkin_service.go, checkin_handler.go, checkin_store.go) mirrors the moment and stamp tagging flows end-to-end so the same engine resolvers credit visits identically across all three.

HTTP endpoints

All JWT-verified. Presence mounts under /presence; check-ins under /checkins.

Method Path Description
POST /api/v1/presence/heartbeat Refresh online TTL (no DB write on the hot path).
POST /api/v1/presence/active/start Start an active-location session. Body {interval_minutes: 30 \| 60 \| 360 \| 720}; any other value is rejected.
POST /api/v1/presence/active/stop Stop the current session immediately.
GET /api/v1/presence/active/status {is_active, interval_minutes, started_at, expires_at} (Redis-first, DB fallback).
POST /api/v1/checkins Publish a check-in. Body {location_id, captured_at?, companions[]?, rating?}. 201 with the row; 400 on bad location or out-of-range rating; 403 on non-friend companion; 429 on throttle.
GET /api/v1/checkins/me The caller's check-ins, newest first. limit (max 200, default 50) + offset.
GET /api/v1/checkins/recent (location_id, checked_in_at) pairs authored inside the 6h throttle window. The capture picker uses these to badge rows that would 429 before the user taps.
GET /api/v1/checkins/:id Detail {checkin, author, location, companions} for the chat link-preview card. Check-ins have no dedicated viewer, so the card is display-only.
DELETE /api/v1/checkins/:id/companions/:userID Untag. :userID is a UUID or me. Author can untag anyone they tagged; a companion can untag themselves; third parties get 403. 204 on success, 404 if not tagged.

Realtime presence

  • UpdatePresence(userID) sets presence:{userID} = "1" with a 3-minute TTL, async-touches users.last_active_at, and fires the engine's UserActivated signal (throttled producer-side) so time-sensitive challenges re-evaluate.
  • StartActiveLocation validates the interval, writes a JSON session blob to Redis with matching TTL, deactivates prior DB rows, then inserts a new ActiveLocationSession.
  • StopActiveLocation deletes the Redis key and marks all DB rows inactive.
  • GetActiveLocationStatus reads Redis first; on a miss it falls back to active_location_sessions and restores the Redis key from the row's remaining TTL.

Check-in publish path

CreateCheckin runs in a fixed order:

  1. Validate rating (0 = unrated, otherwise 1.0-5.0).
  2. Resolve the location; a missing row or blank country returns ErrLocationRequired (400).
  3. Friend-gate every companion; one non-friend short-circuits the whole publish with 403.
  4. Throttle: reject with 429 if a row exists for the same (user_id, location_id) inside the last 6 hours.
  5. Insert the user_checkins row; fold the rating into the location aggregate (best-effort).
  6. Fan out travel-log entries: one for the author (TravelLogSourceCheckin), one per companion (TravelLogSourceTaggedCheckin).
  7. Per companion: insert a checkin_companions row, emit a checkin.tagged notification, and fire one "Tagged you in check-in" DM.
  8. Silent event check-in: when the picked location is anchored to an active event of theirs, flip event_participants.checked_in_at (best-effort, via EventCheckinMarker).

Data model

Table / key Notes
active_location_sessions One active row per user (DeactivateAll runs before each Create). Restart-safe fallback when the Redis key is gone.
presence:{userID} (Redis) "1", TTL 3 minutes. No DB row.
active_location:{userID} (Redis) JSON {interval_minutes, started_at, expires_at}, TTL = session interval.
user_checkins (id, user_id, location_id, captured_at, created_at). FK to users cascades; FK to locations is RESTRICT so a location with check-ins can't vanish. Indexed (user_id, location_id, captured_at DESC) for the throttle check and the passport read.
checkin_companions Composite PK (checkin_id, user_id) + created_at. FKs cascade. Indexed by user_id for the tagged-feed read and the user-delete cleanup.

Dependencies

  • cache.Cache — presence + active-location keys.
  • ActiveLocationStoreCreate, FindActive, DeactivateAll.
  • user.UserStore — async TouchLastActiveAt; actor previews for CheckinDetail.
  • UserActivatedSubscriber — the game-engine hook fired on heartbeat (nil disables it).
  • CheckinStore — row + companion CRUD, RecentExists (throttle), RecentFriendVisitors (the find-locations "friends were here" stack), DeleteCompanionsForUser (user-delete cleanup).
  • friend.FriendStore.AreFriends — companion friend-gate.
  • location.LocationStore.GetByID — country snapshot for the travel-log entry.
  • passport.PassportService.Record — author + per-companion travel-log writes. See Passport.
  • EventCheckinMarker — the narrow event port for the silent event check-in.
  • notify.Notifier — emits checkin.tagged per companion.
  • TagDMSender.SendTagNotice — the companion-tag DM; share URL {shareBaseURL}/checkins/<id>. See Chat -> tag-notice DMs.

Notable behavior

Why both Redis and Postgres for presence

Redis owns the hot path: WS presence checks, friend feeds, and discovery marker decoration all MGET against presence:* and active_location:*. Postgres is the recovery store; if Redis is wiped, GetActiveLocationStatus rehydrates the key from active_location_sessions so the user doesn't have to re-tap "Share live location" after a redeploy.

Heartbeat TTL is 3 minutes

The client must heartbeat at least every 3 minutes or the user reads as offline. Going offline is implicit (TTL expiry); there is no presence-stop endpoint.

Activity-status setting gates the online flag

A live presence:{userID} key alone isn't enough. The friends/discovery consumer also checks User.ChatPreferences.ActivityStatus; users who disabled "show activity status" read offline even with a fresh TTL.

Check-in throttle is 6h per (user, location)

The simplest defense against double-taps. The check runs against captured_at, not created_at, so a backdated client publish still trips the throttle if it would have collided in real time.

Off-grid check-ins are not supported

A check-in must resolve to a location with a non-empty country. Without a country anchor there is no passport stat to credit, so CreateCheckin returns ErrLocationRequired (400). The capture flow guarantees a location pick before this path.

Untag leaves the audit trail

UntagUser removes only the checkin_companions row; the travel-log entry written at publish stays. The engine has already observed the presence and may have credited stamps or moved challenge progress, so retracting it would create spurious "you lost a stamp" events.

Realtime and emits

Presence itself emits no WebSocket frames; it publishes Redis keys that the friend and discovery domains read to decorate friend markers. Check-in checkin.tagged notifications (inbox + push + WS) ride the notification pipeline's /ws/client bus; the payload carries location_id + location_name so the FE row can route a tap to the location on the map without a follow-up fetch. See Notifications.

Where to look