From 53ad0adead0f0df5424be9f61c49e3466c91096e Mon Sep 17 00:00:00 2001 From: Matiss Jurevics Date: Mon, 23 Feb 2026 14:35:00 +0000 Subject: [PATCH] feat(devices): compute effective online status with stale heartbeat ttl --- Backend/.env.example | 3 +- Backend/README.md | 1 + Backend/routes/devices.ts | 16 +++++++- Backend/tests/device-status.test.ts | 60 +++++++++++++++++++++++++++++ Backend/tests/env.test.ts | 23 ++++++++++- Backend/utils/device-status.ts | 32 +++++++++++++++ Backend/utils/env.ts | 16 ++++++++ 7 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 Backend/tests/device-status.test.ts create mode 100644 Backend/utils/device-status.ts diff --git a/Backend/.env.example b/Backend/.env.example index e12b2cf..acc3ecb 100644 --- a/Backend/.env.example +++ b/Backend/.env.example @@ -1,8 +1,9 @@ DATABASE_URL=postgres://username:password@localhost:5432/database_name -BETTER_AUTH_SECRET=replace_with_a_long_random_secret +BETTER_AUTH_SECRET=7bXC7LC/1IWRG3XfojTR+m9c/mN2kXLzN+g1j+cB96VuMFXml2DG8p4/3RvpMH8Tax5FXN2Bv4tjR+8/Qca0Jg BETTER_AUTH_BASE_URL=http://localhost:3000 BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:5173 PORT=3000 +DEVICE_ONLINE_STALE_SECONDS=30 MINIO_ENDPOINT=localhost MINIO_PORT=9000 MINIO_USE_SSL=false diff --git a/Backend/README.md b/Backend/README.md index 7c5c9cc..ce857e5 100644 --- a/Backend/README.md +++ b/Backend/README.md @@ -34,6 +34,7 @@ Required env vars: | `BETTER_AUTH_BASE_URL` | Public base URL for the backend (e.g., `http://localhost:3000`) | | `BETTER_AUTH_TRUSTED_ORIGINS` | Comma-separated list of allowed frontend origins | | `PORT` | HTTP port (default `3000`) | +| `DEVICE_ONLINE_STALE_SECONDS` | Presence TTL in seconds before an `online` device is reported as `offline` (default `30`) | | `MEDIA_MODE` | Media runtime mode (`legacy` default, `single_server_sfu` scaffold mode) | | `MEDIA_PROVIDER` | Media backend provider (`mock` by default) | | `TURN_URLS` / `TURN_USERNAME` / `TURN_CREDENTIAL` | TURN/STUN configuration used by single-server SFU mode | diff --git a/Backend/routes/devices.ts b/Backend/routes/devices.ts index 565cf86..f01a394 100644 --- a/Backend/routes/devices.ts +++ b/Backend/routes/devices.ts @@ -7,6 +7,8 @@ import { deviceLinks, devices } from '../db/schema'; import { requireAuth } from '../middleware/auth'; import { requireDeviceAuth } from '../middleware/device-auth'; import { createDeviceToken } from '../utils/device-token'; +import { getEffectiveDeviceStatus } from '../utils/device-status'; +import { getDeviceOnlineStaleSeconds } from '../utils/env'; const router = Router(); @@ -126,7 +128,19 @@ router.get('/', requireAuth, async (req, res) => { orderBy: [desc(devices.updatedAt)], }); - res.json({ count: result.length, devices: result }); + const now = new Date(); + const staleAfterSeconds = getDeviceOnlineStaleSeconds(); + const effectiveDevices = result.map((device) => ({ + ...device, + status: getEffectiveDeviceStatus({ + status: device.status, + lastSeenAt: device.lastSeenAt, + now, + staleAfterSeconds, + }), + })); + + res.json({ count: effectiveDevices.length, devices: effectiveDevices }); }); router.patch('/:deviceId', requireAuth, async (req, res) => { diff --git a/Backend/tests/device-status.test.ts b/Backend/tests/device-status.test.ts new file mode 100644 index 0000000..7c2c71e --- /dev/null +++ b/Backend/tests/device-status.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'bun:test'; + +import { getEffectiveDeviceStatus } from '../utils/device-status'; + +describe('device status helper', () => { + test('reports online when status is online and heartbeat is fresh', () => { + const now = new Date('2026-02-25T12:00:00.000Z'); + const lastSeenAt = new Date('2026-02-25T11:59:45.000Z'); + + const status = getEffectiveDeviceStatus({ + status: 'online', + lastSeenAt, + now, + staleAfterSeconds: 30, + }); + + expect(status).toBe('online'); + }); + + test('reports offline when heartbeat is stale', () => { + const now = new Date('2026-02-25T12:00:00.000Z'); + const lastSeenAt = new Date('2026-02-25T11:59:00.000Z'); + + const status = getEffectiveDeviceStatus({ + status: 'online', + lastSeenAt, + now, + staleAfterSeconds: 30, + }); + + expect(status).toBe('offline'); + }); + + test('reports offline when stored status is not online', () => { + const now = new Date('2026-02-25T12:00:00.000Z'); + const lastSeenAt = new Date('2026-02-25T11:59:55.000Z'); + + const status = getEffectiveDeviceStatus({ + status: 'offline', + lastSeenAt, + now, + staleAfterSeconds: 30, + }); + + expect(status).toBe('offline'); + }); + + test('reports offline when lastSeenAt is missing', () => { + const now = new Date('2026-02-25T12:00:00.000Z'); + + const status = getEffectiveDeviceStatus({ + status: 'online', + lastSeenAt: null, + now, + staleAfterSeconds: 30, + }); + + expect(status).toBe('offline'); + }); +}); diff --git a/Backend/tests/env.test.ts b/Backend/tests/env.test.ts index a9f6fd7..607d877 100644 --- a/Backend/tests/env.test.ts +++ b/Backend/tests/env.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from 'bun:test'; -import { getBetterAuthBaseUrl, getFirstDefinedEnv } from '../utils/env'; +import { getBetterAuthBaseUrl, getDeviceOnlineStaleSeconds, getFirstDefinedEnv } from '../utils/env'; const ORIGINAL_ENV = { ...process.env }; @@ -22,4 +22,25 @@ describe('env helpers', () => { expect(getBetterAuthBaseUrl()).toBe('http://base-url:4000'); }); + + test('getDeviceOnlineStaleSeconds defaults to 30', () => { + delete process.env.DEVICE_ONLINE_STALE_SECONDS; + expect(getDeviceOnlineStaleSeconds()).toBe(30); + }); + + test('getDeviceOnlineStaleSeconds parses valid positive integer values', () => { + process.env.DEVICE_ONLINE_STALE_SECONDS = '45'; + expect(getDeviceOnlineStaleSeconds()).toBe(45); + }); + + test('getDeviceOnlineStaleSeconds falls back to default on invalid values', () => { + process.env.DEVICE_ONLINE_STALE_SECONDS = '0'; + expect(getDeviceOnlineStaleSeconds()).toBe(30); + + process.env.DEVICE_ONLINE_STALE_SECONDS = '-2'; + expect(getDeviceOnlineStaleSeconds()).toBe(30); + + process.env.DEVICE_ONLINE_STALE_SECONDS = 'abc'; + expect(getDeviceOnlineStaleSeconds()).toBe(30); + }); }); diff --git a/Backend/utils/device-status.ts b/Backend/utils/device-status.ts new file mode 100644 index 0000000..0b839e7 --- /dev/null +++ b/Backend/utils/device-status.ts @@ -0,0 +1,32 @@ +import { getDeviceOnlineStaleSeconds } from './env'; + +export type EffectiveDeviceStatus = 'online' | 'offline'; + +type EffectiveDeviceStatusParams = { + status: string | null | undefined; + lastSeenAt: Date | null | undefined; + now?: Date; + staleAfterSeconds?: number; +}; + +export const getEffectiveDeviceStatus = ({ + status, + lastSeenAt, + now = new Date(), + staleAfterSeconds = getDeviceOnlineStaleSeconds(), +}: EffectiveDeviceStatusParams): EffectiveDeviceStatus => { + if (status !== 'online') { + return 'offline'; + } + + if (!(lastSeenAt instanceof Date) || Number.isNaN(lastSeenAt.getTime())) { + return 'offline'; + } + + const elapsedMs = now.getTime() - lastSeenAt.getTime(); + if (elapsedMs < 0) { + return 'online'; + } + + return elapsedMs <= staleAfterSeconds * 1000 ? 'online' : 'offline'; +}; diff --git a/Backend/utils/env.ts b/Backend/utils/env.ts index 30b4492..2106fc4 100644 --- a/Backend/utils/env.ts +++ b/Backend/utils/env.ts @@ -32,3 +32,19 @@ export const getRequiredEnv = (name: string): string => { export const getBetterAuthBaseUrl = (): string => { return getFirstDefinedEnv('BETTER_AUTH_BASE_URL', 'BETTER_AUTH_URL') ?? `http://localhost:${process.env.PORT ?? '3000'}`; }; + +const DEFAULT_DEVICE_ONLINE_STALE_SECONDS = 30; + +export const getDeviceOnlineStaleSeconds = (): number => { + const value = getFirstDefinedEnv('DEVICE_ONLINE_STALE_SECONDS'); + if (!value) { + return DEFAULT_DEVICE_ONLINE_STALE_SECONDS; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return DEFAULT_DEVICE_ONLINE_STALE_SECONDS; + } + + return parsed; +};