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 futurestatus:<org>:<user>model is its §7.4 future note + §14).
Operations
| OperationID | Method + Path | Security | Notes |
|---|---|---|---|
send-presence-heartbeat | POST /presence/heartbeat | cookie | liveness — Centrifugo OSS has no disconnect webhook, so heartbeat + stale-cron timeout is the signal |
set-manual-status | POST /presence/status/update | cookie | manual override; persisted (survives reconnect until changed) |
get-presence-snapshot | POST /presence/status/get | cookie | batch 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) andUser.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 whichstatus:channels the client subscribes to.
NOT in the typed client contract
POST /internal/centrifugo/subscribe(subscribe-proxy, §5/§9) — Centrifugo→backend, internal network only; authorizesstatus: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 toMember.presenceStatus/User.manualStatus; all three must stay in lockstep (busyalready reconciled across them). - Batch:
get-presence-snapshotis 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). Theuser_presencetable is kept & extended (manual_status/heartbeat_atalready exist;busyis 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.