Skip to main content

Resource: Presence (user-status)

Tag: Presence · Module: presence

Cross-cutting, not a chat feature: the status:<org>:<user> snapshots feed the recent-DM sidebar, rendered space occupants, and profile cards — chat is one consumer among several. Presence rides the shared Centrifugo connection (token in realtime.md) but is its own domain, at /presence/*. Status truth lives in Postgres (user_presence); Centrifugo delivers only changes (snapshot + delta).

Detail owned by centrifugo_chat_poc.md §7.4 (existing presence system; the future status:<org>:<user> model is its §7.4 future note + §14).


Operations

OperationIDMethod + PathSecurityNotes
send-presence-heartbeatPOST /presence/heartbeatcookieliveness — Centrifugo OSS has no disconnect webhook, so heartbeat + stale-cron timeout is the signal
set-manual-statusPOST /presence/status/updatecookiemanual override; persisted (survives reconnect until changed)
get-presence-snapshotPOST /presence/status/getcookiebatch read — { userIds:[…] } → effective status per user; the "snapshot" half of snapshot+delta (initial render, beyond-page rows, reconnect)
// send-presence-heartbeat — empty body → 204
// set-manual-status — Request
{ "status": "online | away | busy | offline" } // C6; "appear offline" = persisted `offline`
// Response — 204 (effective status republishes on the user's status: channel)

// get-presence-snapshot — batch read (C11; request-bounded, no page)
// Request
{ "userIds": ["string"] }
// Response — effective status = COALESCE(manualStatus, automaticStatus)
{ "data": [ { "userId": "string", "status": "online | away | busy | offline" } ] }
  • The live delta arrives on status:<org>:<user> (subscribe-proxy authorized — internal, see below); the client subscribes only to users it renders (refcounted registry, §9), never an org-wide channel.
  • The status enum is identical to Member.presenceStatus (members.md) and User.manualStatus (user.md).
  • Space presence (who is in a space) is a separate system — Supabase/LiveKit, Realtime, out of Centrifugo scope (design R1). It only drives which status: channels the client subscribes to.

NOT in the typed client contract

  • POST /internal/centrifugo/subscribe (subscribe-proxy, §5/§9) — Centrifugo→backend, internal network only; authorizes status: subscriptions with a pure same-org string check (no per-channel token, because a user watches 20–70 status channels at once).

Conventions applied

  • Status enum: offline | online | away | busy (C6) — identical to Member.presenceStatus / User.manualStatus; all three must stay in lockstep (busy already reconciled across them).
  • Batch: get-presence-snapshot is a C11 batch read ({userIds}{data}, request-bounded, no page).
  • Timestamps: any heartbeat / expiry times are RFC3339 (C3).

Module / backend notes

  • Own module presence: heartbeat, manual status, status snapshot + publish (§9). The user_presence table is kept & extended (manual_status/heartbeat_at already exist; busy is a new app-side value). The stale-presence cron clears automatic status only, never manual — so "appear offline" persists.
  • One Go service may mint both Centrifugo JWTs (connection + subscription); the contract splits by domain/consumer, not implementation. The shared connection token lives in realtime.md.