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
| Param | Type | Required | Validation | Notes |
|---|---|---|---|---|
userId | string (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"). |
| Header | Required | Notes |
|---|---|---|
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
| Field | Type | Notes |
|---|---|---|
userId | string (UUID) | Echo of the path parameter. |
lastSeen | string | null | ISO-8601 timestamp of the user's most recent disconnect. null when never seen / 30-day TTL expired / Redis read failed. |
isOnline | boolean | true 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
| HTTP | Reason |
|---|---|
400 | userId is not a valid UUID (ParseUUIDPipe rejects). |
401 | Missing / invalid bearer token. |
429 | Rate 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
JwtAuthGuardvalidates the bearer token;ParseUUIDPipevalidatesuserId.- Resolve the session Redis instance. If unavailable โ return
{ userId, lastSeen: null, isOnline: false }(no throw). - Issue two parallel Redis reads against the session instance:
SCARD presence:{userId}โ integer count of live socket IDsGET presence:lastseen:{userId}โ ISO-8601 string ornull
- Combine:
isOnline = (count ?? 0) > 0,lastSeen = value ?? null. - 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
Authorization
bearer In: header
Path Parameters
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
| Source | Path | Lines |
|---|---|---|
| Controller | apps/api-core/src/modules/presence/presence.controller.ts | 20โ37 (class), 27โ36 (getLastSeen) |
| DTO (response) | apps/api-core/src/modules/presence/dto/presence-response.dto.ts | 10โ24 (PresenceLastSeenDto) |
| Service | apps/api-core/src/modules/presence/presence.service.ts | 33โ54 (getPresence โ Redis read + soft-degrade) |
| Redis key constants | apps/api-core/src/modules/presence/presence.service.ts | 14โ15 (PRESENCE_PREFIX = 'presence:', LASTSEEN_PREFIX = 'presence:lastseen:') |
| Write-side (informational) | apps/chat-service/src/presence.ts | 24โ25 (matching key constants โ same Redis instance) |
Get Platform Stats
Public aggregate counters โ active creators, total messages, total earned. CDN- and Redis-cached for 1 hour. Render in trust badges, homepage hero, footer counters.
Get Referral Link
Get-or-create the calling user's personal referral link. First call generates a code from their username (or random UUID slice on collision); subsequent calls return the existing one.