Presence¶
Purpose¶
The presence domain answers "where is this user, right now and recently?" across three related surfaces:
- Online? — a short-TTL Redis key (
presence:{userID}) the client refreshes with a heartbeat. - Sharing live location? — a typed active-location session (one of four intervals) backed by Redis plus a DB row for crash recovery.
- Been somewhere? — check-ins: the lowest-friction visit producer, a
user_checkinsrow 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)setspresence:{userID}="1"with a 3-minute TTL, async-touchesusers.last_active_at, and fires the engine'sUserActivatedsignal (throttled producer-side) so time-sensitive challenges re-evaluate.StartActiveLocationvalidates the interval, writes a JSON session blob to Redis with matching TTL, deactivates prior DB rows, then inserts a newActiveLocationSession.StopActiveLocationdeletes the Redis key and marks all DB rows inactive.GetActiveLocationStatusreads Redis first; on a miss it falls back toactive_location_sessionsand restores the Redis key from the row's remaining TTL.
Check-in publish path¶
CreateCheckin runs in a fixed order:
- Validate
rating(0 = unrated, otherwise 1.0-5.0). - Resolve the location; a missing row or blank country returns
ErrLocationRequired(400). - Friend-gate every companion; one non-friend short-circuits the whole publish with 403.
- Throttle: reject with 429 if a row exists for the same
(user_id, location_id)inside the last 6 hours. - Insert the
user_checkinsrow; fold the rating into the location aggregate (best-effort). - Fan out travel-log entries: one for the author (
TravelLogSourceCheckin), one per companion (TravelLogSourceTaggedCheckin). - Per companion: insert a
checkin_companionsrow, emit acheckin.taggednotification, and fire one "Tagged you in check-in" DM. - Silent event check-in: when the picked location is anchored to an active event of theirs, flip
event_participants.checked_in_at(best-effort, viaEventCheckinMarker).
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.ActiveLocationStore—Create,FindActive,DeactivateAll.user.UserStore— asyncTouchLastActiveAt; actor previews forCheckinDetail.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— emitscheckin.taggedper 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¶
backend/internal/services/presence/presence_service.go,presence_handler.go,active_location_store.gobackend/internal/services/presence/checkin_service.go,checkin_handler.go,checkin_store.gobackend/internal/models/active_location.go,user_checkin.go- Consumers:
backend/internal/services/friend/(friend locations),backend/internal/services/discovery/(radar + friend markers) frontend/services/checkinService.ts,frontend/services/eventCheckinService.ts