BIO.RE
Presence

Get Last-Seen + Online Status

REST read of socket-written presence state. Returns isOnline (live socket connection) and lastSeen (ISO-8601 of final disconnect, ~30-day TTL). Soft-degrades to "offline + null" when the session Redis is unreachable.

GET /api/v1/presence/:userId/last-seen โ€” ๐Ÿ”‘ Bearer ยท Rate limit: 60 req / minute

Read the live presence snapshot for any user โ€” typically rendered as a green dot ("online") or "Last seen 5 min ago" in chat surfaces, profile cards, and the inbox list. The state is written by chat-service (Socket.IO connect / disconnect events) and read by api-core through a shared session-Redis instance โ€” this endpoint is the read side.

Two Redis keys, two semantics, one snapshot. isOnline is derived from presence:{userId} (a Redis SET of active socket IDs, TTL 60s โ€” scard > 0 means at least one live socket). lastSeen is read from presence:lastseen:{userId} (ISO-8601 string, TTL ~30 days). Both reads are issued in parallel; the response combines them. No DB hop.

lastSeen: null is ambiguous on purpose. It means one of: the user has never connected, the 30-day TTL has expired, or the session Redis was unreachable on this request. Don't render "Last seen never" with confidence โ€” show "Last seen a while ago" or simply hide the row. The endpoint cannot distinguish these cases.

Soft-degrades to "offline" when Redis session is down. If the session Redis is unavailable or the read throws, the controller still returns 200 with { isOnline: false, lastSeen: null } instead of erroring. Treat absence of online state as "unknown," not "definitely offline" โ€” the user might be live but the read just failed. Surfacing a stale "online" dot is worse than a missing dot, so this is the right default โ€” but log/Sentry your own client retry policy if you build off this.

v1 has no privacy gate. Any authenticated user can read presence for any other userId. There is no "hide online status" preference on UserSettings yet โ€” that's a planned follow-up. Don't expose this endpoint in your UI as if it were privacy-aware: the user being looked up has no opt-out.

Request

Path parameters

ParamTypeRequiredValidationNotes
userIdstring (UUID)โœ“ParseUUIDPipe (Nest built-in โ€” rejects non-UUID with 400)Target user. Any UUID is accepted; non-existent userIds return isOnline: false, lastSeen: null (no 404 โ€” Redis miss looks identical to "never seen").
HeaderRequiredNotes
Authorization: Bearer <accessToken>โœ“JWT from POST /auth/login. JwtAuthGuard enforces.

No body, no query params.

Response

200 OK โ€” ApiResponseOf<PresenceLastSeenDto>

{
  "success": true,
  "data": {
    "userId": "00000000-0000-4000-8000-000000000001",
    "lastSeen": "2026-04-24T12:34:56.000Z",
    "isOnline": false
  }
}

Fields

FieldTypeNotes
userIdstring (UUID)Echo of the path parameter.
lastSeenstring | nullISO-8601 timestamp of the user's most recent disconnect. null when never seen / 30-day TTL expired / Redis read failed.
isOnlinebooleantrue iff at least one socket ID is in the live presence set (scard(presence:{userId}) > 0). The set has a 60s TTL โ€” sockets that drop without a clean disconnect age out automatically.

Errors

HTTPReason
400userId is not a valid UUID (ParseUUIDPipe rejects).
401Missing / invalid bearer token.
429Rate limit exceeded (60 req/min).

No 404 for unknown users. A nonexistent or never-seen userId returns 200 with { isOnline: false, lastSeen: null } โ€” the Redis miss path doesn't validate that the user row exists. If you need to confirm the user is real, hit GET /users/:id separately.

Side effects

  1. JwtAuthGuard validates the bearer token; ParseUUIDPipe validates userId.
  2. Resolve the session Redis instance. If unavailable โ†’ return { userId, lastSeen: null, isOnline: false } (no throw).
  3. Issue two parallel Redis reads against the session instance:
    • SCARD presence:{userId} โ†’ integer count of live socket IDs
    • GET presence:lastseen:{userId} โ†’ ISO-8601 string or null
  4. Combine: isOnline = (count ?? 0) > 0, lastSeen = value ?? null.
  5. On any Redis exception โ†’ log warning, return { lastSeen: null, isOnline: false }. No retry, no Sentry side effect, no DB write.

This endpoint is strictly read-only โ€” the keys are written by apps/chat-service/src/presence.ts on Socket.IO connect (add socketId to set, refresh TTL) / disconnect (remove socketId, write lastseen timestamp on the final disconnect).

Code samples

curl https://api.bio.re/api/v1/presence/00000000-0000-4000-8000-000000000001/last-seen \
  -H "Authorization: Bearer $ACCESS_TOKEN"
type PresenceSnapshot = {
  userId: string;
  lastSeen: string | null; // ISO-8601 or null
  isOnline: boolean;
};

async function getPresence(
  accessToken: string,
  userId: string,
): Promise<PresenceSnapshot> {
  const res = await fetch(
    `https://api.bio.re/api/v1/presence/${userId}/last-seen`,
    { headers: { Authorization: `Bearer ${accessToken}` } },
  );
  const json = await res.json();
  if (!res.ok || !json.success) {
    throw Object.assign(new Error(json?.error?.message ?? 'Presence fetch failed'), {
      code: json?.error?.code,
    });
  }
  return json.data;
}
import { useQuery } from '@tanstack/react-query';

export const presenceKeys = {
  forUser: (userId: string) => ['presence', userId] as const,
};

// Poll every 60s โ€” matches the presence:{userId} Redis TTL.
// Sockets that disconnect cleanly write lastSeen immediately;
// sockets that drop will age out within 60s, so polling at this
// cadence keeps the UI within one tick of reality.
export function usePresence(userId: string, enabled = true) {
  return useQuery({
    queryKey: presenceKeys.forUser(userId),
    queryFn: async () => {
      const res = await fetch(`/api/v1/presence/${userId}/last-seen`);
      const json = await res.json();
      if (!res.ok || !json.success) {
        throw Object.assign(new Error(json?.error?.message ?? 'Presence fetch failed'), {
          code: json?.error?.code,
        });
      }
      return json.data as PresenceSnapshot;
    },
    enabled,
    refetchInterval: 60_000,
    // Stale = the moment we get the response. We want fresh on remount.
    staleTime: 0,
  });
}
function formatPresence(snap: PresenceSnapshot, now = Date.now()): string {
  if (snap.isOnline) return 'Online';
  if (!snap.lastSeen) return 'Last seen a while ago'; // ambiguous null โ€” never / TTL / read fail
  const diffMs = now - new Date(snap.lastSeen).getTime();
  const min = Math.floor(diffMs / 60_000);
  if (min < 1) return 'Just now';
  if (min < 60) return `Last seen ${min}m ago`;
  const hr = Math.floor(min / 60);
  if (hr < 24) return `Last seen ${hr}h ago`;
  const day = Math.floor(hr / 24);
  return `Last seen ${day}d ago`;
}

Try it

GET
/api/v1/presence/{userId}/last-seen
AuthorizationBearer <token>

In: header

Path Parameters

userId*string

Response Body

application/json

application/json

application/json

curl -X GET "https://loading/api/v1/presence/string/last-seen"
{
  "success": true,
  "data": {
    "userId": "00000000-0000-4000-8000-000000000001",
    "lastSeen": "2026-04-24T12:34:56.000Z",
    "isOnline": false
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}
{
  "success": false,
  "error": {
    "code": "AUTH_UNAUTHORIZED",
    "message": "Invalid credentials",
    "i18nKey": "auth.login.invalid_credentials",
    "i18nVars": {
      "field": "email"
    },
    "details": [
      {
        "message": "email must be an email"
      }
    ],
    "correlationId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
  }
}

Source

SourcePathLines
Controllerapps/api-core/src/modules/presence/presence.controller.ts20โ€“37 (class), 27โ€“36 (getLastSeen)
DTO (response)apps/api-core/src/modules/presence/dto/presence-response.dto.ts10โ€“24 (PresenceLastSeenDto)
Serviceapps/api-core/src/modules/presence/presence.service.ts33โ€“54 (getPresence โ€” Redis read + soft-degrade)
Redis key constantsapps/api-core/src/modules/presence/presence.service.ts14โ€“15 (PRESENCE_PREFIX = 'presence:', LASTSEEN_PREFIX = 'presence:lastseen:')
Write-side (informational)apps/chat-service/src/presence.ts24โ€“25 (matching key constants โ€” same Redis instance)

On this page