diff --git a/CHANGELOG.md b/CHANGELOG.md index f58ae32..ef3b570 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ Changelog +## [4.9.0](https://github.com/MikeDev75015/mongodb-dynamic-api/compare/v4.8.0...v4.9.0) (2026-05-25) + +### presence + +* **presence:** add @Public() to PresenceController and fix e2e race conditions ([6245e6d](https://github.com/MikeDev75015/mongodb-dynamic-api/commit/6245e6d265d6a086d3aa6813df3c1cd71fa7303d)) +* **presence:** add DynamicApiPresenceModule with pluggable adapters ([5957c54](https://github.com/MikeDev75015/mongodb-dynamic-api/commit/5957c54b62664f5861123468f50068861aff7b78)) +* **presence:** break circular dependency by moving presence out of modules barrel ([51fad6a](https://github.com/MikeDev75015/mongodb-dynamic-api/commit/51fad6a7fe8b7dd3472bbd27fd25ff6f70a80267)) +* **presence:** replace String(err) with JSON.stringify in gateway error handler ([8dcaa04](https://github.com/MikeDev75015/mongodb-dynamic-api/commit/8dcaa04499ef0dbacb988b9a0acddbf463ac68be)) + ## [4.8.0](https://github.com/MikeDev75015/mongodb-dynamic-api/compare/v4.7.0...v4.8.0) (2026-05-24) ### cascade diff --git a/README.md b/README.md index 4188fbf..13c57fa 100644 --- a/README.md +++ b/README.md @@ -895,6 +895,7 @@ Explore advanced features and configurations: | 🔐 **Authentication** | JWT dual-token auth (8 endpoints) — access + refresh tokens, cookie mode, server-side revocation | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/authentication.md) | | 🛡️ **Authorization** | Role-based access control | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/authorization.md) | | 📡 **WebSockets** | Socket.IO integration for routes | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/websockets.md) | +| 🟢 **Presence** | Real-time online/offline tracking for WebSocket users | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/presence.md) | | 🗂️ **Route Configuration** | All route options: DTOs, interceptors, etc. | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/route-config.md) | | 🔁 **Callbacks** | beforeSave / afterSave hooks, typed contexts, user access, audit trails | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/callbacks.md) | | 🎛️ **Controller Configuration** | All `controllerOptions` and `forFeature` options | [View Guide](https://github.com/MikeDev75015/mongodb-dynamic-api/blob/main/README/controller-config.md) | diff --git a/README/presence.md b/README/presence.md new file mode 100644 index 0000000..b253058 --- /dev/null +++ b/README/presence.md @@ -0,0 +1,250 @@ +# Presence Management + +`DynamicApiPresenceModule` tracks the **online/offline status** of authenticated WebSocket users in real time. +It supports multi-tab connections (a user stays online until every socket disconnects) and an optional HTTP endpoint. + +--- + +## Table of Contents + +- [Quick start](#quick-start) +- [Module options](#module-options) +- [Adapters](#adapters) + - [InMemoryPresenceAdapter](#inmemorypresenceadapter) + - [RedisPresenceAdapter](#redispresenceadapter) +- [WebSocket events](#websocket-events) +- [HTTP endpoint (optional)](#http-endpoint-optional) +- [Dependency injection](#dependency-injection) +- [Architecture notes](#architecture-notes) + +--- + +## Quick start + +```typescript +// app.module.ts +import { Module } from '@nestjs/common'; +import { DynamicApiModule, DynamicApiPresenceModule, enableDynamicAPIWebSockets } from 'mongodb-dynamic-api'; + +@Module({ + imports: [ + DynamicApiModule.forRoot('mongodb://localhost/mydb', { + useAuth: { + userEntity: UserEntity, + jwt: { secret: 'my-secret', expiresIn: '1h' }, + login: {}, + webSocket: true, + }, + }), + + // ← register presence alongside DynamicApiModule + DynamicApiPresenceModule.register({ + adapter: 'memory', // 'memory' | 'redis' + enableController: true, // expose GET /presence + }), + ], +}) +export class AppModule {} +``` + +In your bootstrap function, call `enableDynamicAPIWebSockets` so the `SocketAdapter` decodes JWTs and populates `socket.user`: + +```typescript +// main.ts +import { enableDynamicAPIWebSockets } from 'mongodb-dynamic-api'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + enableDynamicAPIWebSockets(app); // ← required for presence to work + await app.listen(3000); +} +``` + +--- + +## Module options + +```typescript +interface PresenceRegisterOptions { + /** + * Storage backend. + * - `'memory'` — in-process Map. Single-instance / dev use. + * - `'redis'` — ioredis. Multi-instance / production. + */ + adapter: 'memory' | 'redis'; + + /** + * Redis connection URL — required when `adapter === 'redis'`. + * @example 'redis://localhost:6379' + */ + redisUrl?: string; + + /** + * TTL in seconds applied to every Redis key on write. + * Acts as an automatic heartbeat safety-net (stale entries from crashed + * processes expire). Refreshed on every `setOnline` call. + * @default 60 + */ + redisTtlSeconds?: number; + + /** + * Expose `GET /presence[?room=]` HTTP endpoint. + * @default false + */ + enableController?: boolean; +} +``` + +### Example — Redis with custom TTL + +```typescript +DynamicApiPresenceModule.register({ + adapter: 'redis', + redisUrl: process.env.REDIS_URL, + redisTtlSeconds: 90, + enableController: true, +}) +``` + +--- + +## Adapters + +Both adapters implement the same `PresenceAdapter` interface: + +```typescript +interface PresenceAdapter { + /** Mark userId + socketId as online, optionally bound to a room. */ + setOnline(userId: string, socketId: string, room?: string): Promise; + + /** Remove a socket from the user's online set. */ + setOffline(userId: string, socketId: string): Promise; + + /** Returns true when the user has at least one active socket. */ + isOnline(userId: string): Promise; + + /** Returns online user IDs, filtered by room when provided. */ + getOnlineUserIds(room?: string): Promise; + + /** Returns the number of active sockets for the user (multi-tab). */ + getSocketCount(userId: string): Promise; +} +``` + +### InMemoryPresenceAdapter + +In-process. No external dependencies. Best for single-instance deployments and development. + +- State is lost on process restart. +- Multi-tab: each `(userId, socketId)` pair is tracked independently. + +### RedisPresenceAdapter + +Redis-backed via [ioredis](https://github.com/redis/ioredis). Suitable for distributed / multi-instance deployments. + +**Key schema:** + +| Key | Type | Content | +|-----|------|---------| +| `presence:sockets:{userId}` | SET | Active socket IDs | +| `presence:room:{roomId}` | SET | User IDs in a room | + +- Every key gets an `EXPIRE` (default: 60 s) refreshed on each `setOnline`. + This is a safety-net: if a process crashes, stale entries expire automatically. +- `setOffline` uses an atomic **Lua script** (SREM + DEL-if-empty) to avoid race conditions in multi-instance deployments. + +--- + +## WebSocket events + +The `PresenceGateway` shares the same socket.io namespace as the other DynamicApi gateways (no second server). + +| Event | Direction | Payload | Description | +|-------|-----------|---------|-------------| +| `user:online` | Server → all clients | `{ userId: string }` | Emitted when an authenticated user connects. | +| `user:offline` | Server → all clients | `{ userId: string }` | Emitted when a user's **last** socket disconnects. | + +### Client-side example + +```typescript +import { io } from 'socket.io-client'; + +const socket = io('http://localhost:3000', { + query: { accessToken: '' }, +}); + +socket.on('user:online', ({ userId }) => console.log(`${userId} is online`)); +socket.on('user:offline', ({ userId }) => console.log(`${userId} went offline`)); +``` + +--- + +## HTTP endpoint (optional) + +Enable with `enableController: true` in `register()`. + +> **Note:** The endpoint is decorated with `@Public()` internally, so it remains accessible even when the `DynamicApiJwtAuthGuard` global guard is active — no JWT token is required to query it. + +### `GET /presence` + +Returns all online user IDs. + +```json +{ "onlineUserIds": ["64a1...", "64b2..."] } +``` + +### `GET /presence?room=` + +Returns online user IDs in a specific room. + +```json +{ "onlineUserIds": ["64a1..."] } +``` + +--- + +## Dependency injection + +Inject the adapter anywhere in your application using the `DYNAMIC_API_PRESENCE_ADAPTER` token: + +```typescript +import { Inject, Injectable } from '@nestjs/common'; +import { + DYNAMIC_API_PRESENCE_ADAPTER, + PresenceAdapter, +} from 'mongodb-dynamic-api'; + +@Injectable() +export class ChatService { + constructor( + @Inject(DYNAMIC_API_PRESENCE_ADAPTER) + private readonly presence: PresenceAdapter, + ) {} + + async getOnlineFriends(roomId: string): Promise { + return this.presence.getOnlineUserIds(roomId); + } + + async isUserOnline(userId: string): Promise { + return this.presence.isOnline(userId); + } +} +``` + +--- + +## Architecture notes + +1. **JWT decoding** is performed by `SocketAdapter` (wired by `enableDynamicAPIWebSockets`). It sets `socket.user` before any gateway listener fires. + +2. **Connection tracking** is handled entirely by `PresenceGateway.afterInit(server)`: + - Registers a `connection` listener via `server.on('connection', ...)`. + - Each socket connect → `setOnline` → emit `user:online`. + - Each socket disconnect → `setOffline` → if `getSocketCount === 0` → emit `user:offline`. + +3. **Same namespace** (Option B): `PresenceGateway` uses the same `@WebSocketGateway(options)` as the other DynamicApi gateways, so no second socket server is created. + +4. **Multi-tab** support is built-in: a user is tracked per `(userId, socketId)` pair. `isOnline` / `user:offline` events only trigger when the last socket disconnects. + +5. **Redis atomicity**: `setOffline` uses a Lua script to SREM + DEL-if-empty atomically, preventing race conditions between concurrent disconnects on different instances. + diff --git a/libs/dynamic-api/src/index.ts b/libs/dynamic-api/src/index.ts index ef7868e..81dcddb 100644 --- a/libs/dynamic-api/src/index.ts +++ b/libs/dynamic-api/src/index.ts @@ -10,6 +10,8 @@ export * from './interfaces'; export * from './logger'; export * from './mixins'; export * from './models'; +export * from './modules'; +export * from './modules/presence'; export * from './routes'; export * from './services'; export * from './utils'; diff --git a/libs/dynamic-api/src/interfaces/dynamic-api-presence.interface.ts b/libs/dynamic-api/src/interfaces/dynamic-api-presence.interface.ts new file mode 100644 index 0000000..7e20f86 --- /dev/null +++ b/libs/dynamic-api/src/interfaces/dynamic-api-presence.interface.ts @@ -0,0 +1,63 @@ +/** Injection token for the PresenceAdapter in NestJS DI. */ +const DYNAMIC_API_PRESENCE_ADAPTER = Symbol('DYNAMIC_API_PRESENCE_ADAPTER'); + +/** + * Contract every presence adapter must satisfy. + * All methods are async to support both in-memory and network-backed (Redis) implementations. + */ +interface PresenceAdapter { + /** Mark userId + socketId as online, optionally bound to a room. */ + setOnline(userId: string, socketId: string, room?: string): Promise; + /** Remove a socket from the user's online set. */ + setOffline(userId: string, socketId: string): Promise; + /** Return true when the user has at least one active socket. */ + isOnline(userId: string): Promise; + /** Return the list of online user IDs, filtered by room when provided. */ + getOnlineUserIds(room?: string): Promise; + /** Return the number of active sockets for a given user (multi-tab support). */ + getSocketCount(userId: string): Promise; +} + +/** + * Options passed to `DynamicApiPresenceModule.register()`. + */ +interface PresenceRegisterOptions { + /** Backend to use — 'memory' (default) or 'redis'. */ + adapter: 'memory' | 'redis'; + /** + * Redis connection URL — required when `adapter === 'redis'`. + * @example 'redis://localhost:6379' + */ + redisUrl?: string; + /** + * TTL in seconds applied to every Redis key on write. + * Prevents stale entries when a process crashes without emitting disconnect. + * @default 60 + */ + redisTtlSeconds?: number; + /** + * Expose a `GET /presence` HTTP endpoint. + * Accepts optional `?room=` query param. + * @default false + */ + enableController?: boolean; +} + +/** Shape of the payload emitted on `user:online` / `user:offline` events. */ +interface PresenceEventPayload { + userId: string; +} + +/** Shape of the `GET /presence` HTTP response. */ +interface PresenceResponse { + onlineUserIds: string[]; +} + +export type { + PresenceAdapter, + PresenceRegisterOptions, + PresenceEventPayload, + PresenceResponse, +}; +export { DYNAMIC_API_PRESENCE_ADAPTER }; + diff --git a/libs/dynamic-api/src/interfaces/index.ts b/libs/dynamic-api/src/interfaces/index.ts index 15c24f7..e2ef156 100644 --- a/libs/dynamic-api/src/interfaces/index.ts +++ b/libs/dynamic-api/src/interfaces/index.ts @@ -20,3 +20,4 @@ export * from './dynamic-api-service-callback.interface'; export * from './dynamic-api-service-provider.interface'; export * from './dynamic-api-swagger-options.type'; export * from './dynamic-api-web-socket.interface'; +export * from './dynamic-api-presence.interface'; diff --git a/libs/dynamic-api/src/modules/presence/adapters/in-memory-presence.adapter.spec.ts b/libs/dynamic-api/src/modules/presence/adapters/in-memory-presence.adapter.spec.ts new file mode 100644 index 0000000..121cf7e --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/adapters/in-memory-presence.adapter.spec.ts @@ -0,0 +1,137 @@ +import { InMemoryPresenceAdapter } from './in-memory-presence.adapter'; + +describe('InMemoryPresenceAdapter', () => { + let adapter: InMemoryPresenceAdapter; + + beforeEach(() => { + adapter = new InMemoryPresenceAdapter(); + }); + + describe('setOnline', () => { + it('should mark a user as online', async () => { + await adapter.setOnline('u1', 'sock-1'); + expect(await adapter.isOnline('u1')).toBe(true); + }); + + it('should track multiple sockets for the same user (multi-tab)', async () => { + await adapter.setOnline('u1', 'sock-1'); + await adapter.setOnline('u1', 'sock-2'); + expect(await adapter.getSocketCount('u1')).toBe(2); + }); + + it('should store the room when provided', async () => { + await adapter.setOnline('u1', 'sock-1', 'room-A'); + const ids = await adapter.getOnlineUserIds('room-A'); + expect(ids).toContain('u1'); + }); + + it('should not double-count the same socketId', async () => { + await adapter.setOnline('u1', 'sock-1'); + await adapter.setOnline('u1', 'sock-1'); + expect(await adapter.getSocketCount('u1')).toBe(1); + }); + }); + + describe('setOffline', () => { + it('should remove one socket but keep user online when other sockets remain', async () => { + await adapter.setOnline('u1', 'sock-1'); + await adapter.setOnline('u1', 'sock-2'); + await adapter.setOffline('u1', 'sock-1'); + expect(await adapter.isOnline('u1')).toBe(true); + expect(await adapter.getSocketCount('u1')).toBe(1); + }); + + it('should mark user as offline when last socket disconnects', async () => { + await adapter.setOnline('u1', 'sock-1'); + await adapter.setOffline('u1', 'sock-1'); + expect(await adapter.isOnline('u1')).toBe(false); + }); + + it('should remove room mapping for the socket', async () => { + await adapter.setOnline('u1', 'sock-1', 'room-A'); + await adapter.setOffline('u1', 'sock-1'); + expect(await adapter.getOnlineUserIds('room-A')).not.toContain('u1'); + }); + + it('should be a no-op when user was never online', async () => { + await expect(adapter.setOffline('unknown', 'sock-x')).resolves.toBeUndefined(); + }); + + it('should be a no-op for unknown socketId of known user', async () => { + await adapter.setOnline('u1', 'sock-1'); + await adapter.setOffline('u1', 'sock-unknown'); + expect(await adapter.isOnline('u1')).toBe(true); + }); + }); + + describe('isOnline', () => { + it('should return false for unknown user', async () => { + expect(await adapter.isOnline('ghost')).toBe(false); + }); + + it('should return true after setOnline', async () => { + await adapter.setOnline('u1', 'sock-1'); + expect(await adapter.isOnline('u1')).toBe(true); + }); + + it('should return false after all sockets disconnect', async () => { + await adapter.setOnline('u1', 'sock-1'); + await adapter.setOffline('u1', 'sock-1'); + expect(await adapter.isOnline('u1')).toBe(false); + }); + }); + + describe('getOnlineUserIds', () => { + it('should return all online user IDs when no room filter', async () => { + await adapter.setOnline('u1', 'sock-1'); + await adapter.setOnline('u2', 'sock-2'); + const ids = await adapter.getOnlineUserIds(); + expect(ids).toEqual(expect.arrayContaining(['u1', 'u2'])); + expect(ids).toHaveLength(2); + }); + + it('should return empty array when nobody is online', async () => { + expect(await adapter.getOnlineUserIds()).toEqual([]); + }); + + it('should filter by room', async () => { + await adapter.setOnline('u1', 'sock-1', 'room-A'); + await adapter.setOnline('u2', 'sock-2', 'room-B'); + expect(await adapter.getOnlineUserIds('room-A')).toEqual(['u1']); + expect(await adapter.getOnlineUserIds('room-B')).toEqual(['u2']); + }); + + it('should return empty array for unknown room', async () => { + await adapter.setOnline('u1', 'sock-1', 'room-A'); + expect(await adapter.getOnlineUserIds('room-X')).toEqual([]); + }); + + it('should not duplicate user when they have multiple sockets in same room', async () => { + await adapter.setOnline('u1', 'sock-1', 'room-A'); + await adapter.setOnline('u1', 'sock-2', 'room-A'); + const ids = await adapter.getOnlineUserIds('room-A'); + expect(ids).toEqual(['u1']); + }); + }); + + describe('getSocketCount', () => { + it('should return 0 for unknown user', async () => { + expect(await adapter.getSocketCount('ghost')).toBe(0); + }); + + it('should return correct count for multi-tab user', async () => { + await adapter.setOnline('u1', 'sock-1'); + await adapter.setOnline('u1', 'sock-2'); + await adapter.setOnline('u1', 'sock-3'); + expect(await adapter.getSocketCount('u1')).toBe(3); + }); + + it('should decrease after one socket disconnects', async () => { + await adapter.setOnline('u1', 'sock-1'); + await adapter.setOnline('u1', 'sock-2'); + await adapter.setOffline('u1', 'sock-1'); + expect(await adapter.getSocketCount('u1')).toBe(1); + }); + }); +}); + diff --git a/libs/dynamic-api/src/modules/presence/adapters/in-memory-presence.adapter.ts b/libs/dynamic-api/src/modules/presence/adapters/in-memory-presence.adapter.ts new file mode 100644 index 0000000..acdf87d --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/adapters/in-memory-presence.adapter.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { PresenceAdapter } from '../../../interfaces'; + +/** + * In-memory implementation of `PresenceAdapter`. + * + * Suitable for single-instance deployments or development. + * Supports multi-tab users: each connection (socketId) is tracked individually; + * a user is considered online as long as at least one socket is active. + * + * Data is kept entirely in two Maps — no external dependencies required. + */ +@Injectable() +export class InMemoryPresenceAdapter implements PresenceAdapter { + /** userId → Set of active socketIds */ + private readonly socketsByUser = new Map>(); + /** socketId → roomId */ + private readonly roomBySocket = new Map(); + + async setOnline(userId: string, socketId: string, room?: string): Promise { + if (!this.socketsByUser.has(userId)) { + this.socketsByUser.set(userId, new Set()); + } + + const sockets = this.socketsByUser.get(userId) ?? new Set(); + sockets.add(socketId); + this.socketsByUser.set(userId, sockets); + + if (room) { + this.roomBySocket.set(socketId, room); + } + } + + async setOffline(userId: string, socketId: string): Promise { + const sockets = this.socketsByUser.get(userId); + + if (sockets) { + sockets.delete(socketId); + if (sockets.size === 0) { + this.socketsByUser.delete(userId); + } + } + + this.roomBySocket.delete(socketId); + } + + async isOnline(userId: string): Promise { + const sockets = this.socketsByUser.get(userId); + return !!(sockets && sockets.size > 0); + } + + async getOnlineUserIds(room?: string): Promise { + if (!room) { + return Array.from(this.socketsByUser.keys()); + } + + const result: string[] = []; + + for (const [userId, sockets] of this.socketsByUser.entries()) { + for (const socketId of sockets) { + if (this.roomBySocket.get(socketId) === room) { + result.push(userId); + break; + } + } + } + + return result; + } + + async getSocketCount(userId: string): Promise { + return this.socketsByUser.get(userId)?.size ?? 0; + } +} + diff --git a/libs/dynamic-api/src/modules/presence/adapters/redis-presence.adapter.spec.ts b/libs/dynamic-api/src/modules/presence/adapters/redis-presence.adapter.spec.ts new file mode 100644 index 0000000..8e836f1 --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/adapters/redis-presence.adapter.spec.ts @@ -0,0 +1,219 @@ +import Redis from 'ioredis'; +import { DEFAULT_REDIS_PRESENCE_TTL, RedisPresenceAdapter } from './redis-presence.adapter'; + +jest.mock('ioredis', () => { + const mockInstance = { + sadd: jest.fn(), + srem: jest.fn(), + scard: jest.fn(), + expire: jest.fn(), + keys: jest.fn(), + smembers: jest.fn(), + eval: jest.fn(), + quit: jest.fn(), + }; + const ctor = jest.fn(() => mockInstance); + (ctor as unknown as Record).__instance = mockInstance; + return { __esModule: true, default: ctor }; +}); + +/** + * Creates a plain object that satisfies the shape of an ioredis Redis client. + * No real Redis connection is made — all commands are jest.fn() stubs. + */ +const createRedisMock = () => ({ + sadd: jest.fn(), + srem: jest.fn(), + scard: jest.fn(), + expire: jest.fn(), + keys: jest.fn(), + smembers: jest.fn(), + eval: jest.fn(), + quit: jest.fn(), +}); + +type RedisMock = ReturnType; + +describe('RedisPresenceAdapter', () => { + let adapter: RedisPresenceAdapter; + let redisMock: RedisMock; + + beforeEach(() => { + jest.clearAllMocks(); + redisMock = createRedisMock(); + // Inject the mock Redis client directly via the overloaded constructor + adapter = new RedisPresenceAdapter(redisMock as never); + }); + + it('should use DEFAULT_REDIS_PRESENCE_TTL = 60', () => { + expect(DEFAULT_REDIS_PRESENCE_TTL).toBe(60); + }); + + describe('constructor with string URL', () => { + it('should instantiate ioredis Redis with the provided URL and lazyConnect option', () => { + const MockRedis = Redis as jest.MockedClass; + MockRedis.mockClear(); + + new RedisPresenceAdapter('redis://localhost:6379'); + + expect(MockRedis).toHaveBeenCalledWith('redis://localhost:6379', { lazyConnect: true }); + }); + }); + + describe('onModuleDestroy', () => { + it('should quit the Redis connection', async () => { + redisMock.quit.mockResolvedValue('OK'); + await adapter.onModuleDestroy(); + expect(redisMock.quit).toHaveBeenCalledTimes(1); + }); + }); + + describe('setOnline', () => { + it('should SADD socketId and EXPIRE the sockets key', async () => { + redisMock.sadd.mockResolvedValue(1); + redisMock.expire.mockResolvedValue(1); + + await adapter.setOnline('u1', 'sock-1'); + + expect(redisMock.sadd).toHaveBeenCalledWith('presence:sockets:u1', 'sock-1'); + expect(redisMock.expire).toHaveBeenCalledWith('presence:sockets:u1', DEFAULT_REDIS_PRESENCE_TTL); + }); + + it('should SADD userId to room key and EXPIRE it when room is provided', async () => { + redisMock.sadd.mockResolvedValue(1); + redisMock.expire.mockResolvedValue(1); + + await adapter.setOnline('u1', 'sock-1', 'room-A'); + + expect(redisMock.sadd).toHaveBeenCalledWith('presence:room:room-A', 'u1'); + expect(redisMock.expire).toHaveBeenCalledWith('presence:room:room-A', DEFAULT_REDIS_PRESENCE_TTL); + }); + + it('should not touch room key when no room is provided', async () => { + redisMock.sadd.mockResolvedValue(1); + redisMock.expire.mockResolvedValue(1); + + await adapter.setOnline('u1', 'sock-1'); + + const saddCalls = (redisMock.sadd as jest.Mock).mock.calls as [string, ...unknown[]][]; + expect(saddCalls.every((args) => !String(args[0]).startsWith('presence:room:'))).toBe(true); + }); + + it('should use custom TTL when adapter is created with non-default ttl', async () => { + const customRedisMock = createRedisMock(); + const customAdapter = new RedisPresenceAdapter(customRedisMock as never, 120); + customRedisMock.sadd.mockResolvedValue(1); + customRedisMock.expire.mockResolvedValue(1); + + await customAdapter.setOnline('u1', 'sock-1'); + + expect(customRedisMock.expire).toHaveBeenCalledWith('presence:sockets:u1', 120); + }); + }); + + describe('setOffline', () => { + it('should call eval with Lua script, sockets key and socketId', async () => { + redisMock.eval.mockResolvedValue(1); + redisMock.scard.mockResolvedValue(1); + + await adapter.setOffline('u1', 'sock-1'); + + expect(redisMock.eval).toHaveBeenCalled(); + const evalArgs = (redisMock.eval as jest.Mock).mock.calls[0] as [ + string, + number, + string, + string, + ]; + expect(evalArgs[2]).toBe('presence:sockets:u1'); + expect(evalArgs[3]).toBe('sock-1'); + }); + + it('should scan and SREM user from all room keys when no sockets remain', async () => { + redisMock.eval.mockResolvedValue(1); + redisMock.scard.mockResolvedValue(0); + redisMock.keys.mockResolvedValue(['presence:room:room-A', 'presence:room:room-B']); + redisMock.srem.mockResolvedValue(1); + + await adapter.setOffline('u1', 'sock-1'); + + expect(redisMock.keys).toHaveBeenCalledWith('presence:room:*'); + expect(redisMock.srem).toHaveBeenCalledWith('presence:room:room-A', 'u1'); + expect(redisMock.srem).toHaveBeenCalledWith('presence:room:room-B', 'u1'); + }); + + it('should NOT scan room keys when user still has active sockets', async () => { + redisMock.eval.mockResolvedValue(1); + redisMock.scard.mockResolvedValue(2); + + await adapter.setOffline('u1', 'sock-1'); + + expect(redisMock.keys).not.toHaveBeenCalled(); + }); + + it('should NOT call srem when no room keys exist', async () => { + redisMock.eval.mockResolvedValue(1); + redisMock.scard.mockResolvedValue(0); + redisMock.keys.mockResolvedValue([]); + + await adapter.setOffline('u1', 'sock-1'); + + expect(redisMock.srem).not.toHaveBeenCalled(); + }); + }); + + describe('isOnline', () => { + it.each([ + [1, true], + [3, true], + [0, false], + ])('scard=%s → isOnline=%s', async (count, expected) => { + redisMock.scard.mockResolvedValue(count); + expect(await adapter.isOnline('u1')).toBe(expected); + expect(redisMock.scard).toHaveBeenCalledWith('presence:sockets:u1'); + }); + }); + + describe('getOnlineUserIds', () => { + it('should return all user IDs stripped from sockets keys when no room filter', async () => { + redisMock.keys.mockResolvedValue([ + 'presence:sockets:u1', + 'presence:sockets:u2', + ]); + + const ids = await adapter.getOnlineUserIds(); + + expect(ids).toEqual(['u1', 'u2']); + }); + + it('should return empty array when no sockets keys exist', async () => { + redisMock.keys.mockResolvedValue([]); + expect(await adapter.getOnlineUserIds()).toEqual([]); + }); + + it('should return smembers of the room set when room is provided', async () => { + redisMock.smembers.mockResolvedValue(['u1', 'u3']); + + const ids = await adapter.getOnlineUserIds('room-A'); + + expect(redisMock.smembers).toHaveBeenCalledWith('presence:room:room-A'); + expect(ids).toEqual(['u1', 'u3']); + }); + }); + + describe('getSocketCount', () => { + it.each([ + [0, 0], + [2, 2], + [5, 5], + ])('scard=%s → count=%s', async (cardValue, expected) => { + redisMock.scard.mockResolvedValue(cardValue); + expect(await adapter.getSocketCount('u1')).toBe(expected); + expect(redisMock.scard).toHaveBeenCalledWith('presence:sockets:u1'); + }); + }); +}); + + + + diff --git a/libs/dynamic-api/src/modules/presence/adapters/redis-presence.adapter.ts b/libs/dynamic-api/src/modules/presence/adapters/redis-presence.adapter.ts new file mode 100644 index 0000000..30480ba --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/adapters/redis-presence.adapter.ts @@ -0,0 +1,122 @@ +import { Injectable, OnModuleDestroy } from '@nestjs/common'; +import Redis from 'ioredis'; +import { PresenceAdapter } from '../../../interfaces'; + +/** Default TTL in seconds applied to every Redis presence key. */ +export const DEFAULT_REDIS_PRESENCE_TTL = 60; + +/** + * Lua script for atomic SREM + DEL-if-empty on a Redis set. + * + * Using a Lua script keeps the read-then-delete sequence atomic (no race + * between two concurrent disconnect events on different instances). + */ +const LUA_SREM_DEL_IF_EMPTY = ` +local removed = redis.call('SREM', KEYS[1], ARGV[1]) +if redis.call('SCARD', KEYS[1]) == 0 then + redis.call('DEL', KEYS[1]) +end +return removed +`; + +/** + * Redis-backed implementation of `PresenceAdapter` using ioredis. + * + * Key schema: + * - `presence:sockets:{userId}` — Redis SET of active socketIds for a user. + * - `presence:room:{roomId}` — Redis SET of online userIds for a room. + * + * Every key is given a TTL (configurable, default 60 s) that is refreshed on + * every `setOnline` call. This acts as a safety-net heartbeat: stale entries + * from crashed processes expire automatically. + * + * `setOffline` uses an atomic Lua script to SREM + DEL-if-empty, avoiding + * race conditions in multi-instance deployments. + */ +@Injectable() +export class RedisPresenceAdapter implements PresenceAdapter, OnModuleDestroy { + private readonly redis: Redis; + private readonly ttl: number; + + /** + * @param redisUrlOrClient - Redis connection URL or an existing `ioredis.Redis` instance. + * Accepting an instance makes the adapter easy to test without a real Redis server. + * @param ttlSeconds - TTL applied to presence keys (default: 60 s). + */ + constructor( + redisUrlOrClient: string | Redis, + ttlSeconds: number = DEFAULT_REDIS_PRESENCE_TTL, + ) { + this.redis = + typeof redisUrlOrClient === 'string' + ? new Redis(redisUrlOrClient, { lazyConnect: true }) + : redisUrlOrClient; + this.ttl = ttlSeconds; + } + + async onModuleDestroy(): Promise { + await this.redis.quit(); + } + + // --------------------------------------------------------------------------- + // Key builders + // --------------------------------------------------------------------------- + + private socketsKey(userId: string): string { + return `presence:sockets:${userId}`; + } + + private roomKey(roomId: string): string { + return `presence:room:${roomId}`; + } + + // --------------------------------------------------------------------------- + // PresenceAdapter implementation + // --------------------------------------------------------------------------- + + async setOnline(userId: string, socketId: string, room?: string): Promise { + const sockKey = this.socketsKey(userId); + await this.redis.sadd(sockKey, socketId); + await this.redis.expire(sockKey, this.ttl); + + if (room) { + const rKey = this.roomKey(room); + await this.redis.sadd(rKey, userId); + await this.redis.expire(rKey, this.ttl); + } + } + + async setOffline(userId: string, socketId: string): Promise { + await this.redis.eval(LUA_SREM_DEL_IF_EMPTY, 1, this.socketsKey(userId), socketId); + + /** Only clean up room memberships once the user has no sockets left. */ + const remaining = await this.redis.scard(this.socketsKey(userId)); + + if (remaining === 0) { + const roomKeys = await this.redis.keys('presence:room:*'); + if (roomKeys.length > 0) { + await Promise.all(roomKeys.map((k) => this.redis.srem(k, userId))); + } + } + } + + async isOnline(userId: string): Promise { + const count = await this.redis.scard(this.socketsKey(userId)); + return count > 0; + } + + async getOnlineUserIds(room?: string): Promise { + if (!room) { + const keys = await this.redis.keys('presence:sockets:*'); + return keys.map((k) => k.replace('presence:sockets:', '')); + } + + return this.redis.smembers(this.roomKey(room)); + } + + async getSocketCount(userId: string): Promise { + return this.redis.scard(this.socketsKey(userId)); + } +} + + diff --git a/libs/dynamic-api/src/modules/presence/index.ts b/libs/dynamic-api/src/modules/presence/index.ts new file mode 100644 index 0000000..c311655 --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/index.ts @@ -0,0 +1,6 @@ +export * from './adapters/in-memory-presence.adapter'; +export * from './adapters/redis-presence.adapter'; +export * from './presence.controller'; +export * from './presence.gateway'; +export * from './presence.module'; + diff --git a/libs/dynamic-api/src/modules/presence/presence.controller.spec.ts b/libs/dynamic-api/src/modules/presence/presence.controller.spec.ts new file mode 100644 index 0000000..0018b9f --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/presence.controller.spec.ts @@ -0,0 +1,71 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../../decorators'; +import { DYNAMIC_API_PRESENCE_ADAPTER } from '../../interfaces'; +import { PresenceController } from './presence.controller'; + +describe('PresenceController', () => { + let controller: PresenceController; + + const mockAdapter = { + getOnlineUserIds: jest.fn(), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [PresenceController], + providers: [ + { provide: DYNAMIC_API_PRESENCE_ADAPTER, useValue: mockAdapter }, + ], + }).compile(); + + controller = module.get(PresenceController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should have @Public() on getOnlineUsers so the global JWT guard does not block it', () => { + const reflector = new Reflector(); + const isPublic = reflector.get(IS_PUBLIC_KEY, controller.getOnlineUsers); + expect(isPublic).toBe(true); + }); + + describe('getOnlineUsers', () => { + it('should return all online user IDs when no room filter is provided', async () => { + mockAdapter.getOnlineUserIds.mockResolvedValue(['u1', 'u2']); + + const result = await controller.getOnlineUsers(); + + expect(result).toEqual({ onlineUserIds: ['u1', 'u2'] }); + expect(mockAdapter.getOnlineUserIds).toHaveBeenCalledWith(undefined); + }); + + it('should pass room param to adapter when provided', async () => { + mockAdapter.getOnlineUserIds.mockResolvedValue(['u3']); + + const result = await controller.getOnlineUsers('room-A'); + + expect(result).toEqual({ onlineUserIds: ['u3'] }); + expect(mockAdapter.getOnlineUserIds).toHaveBeenCalledWith('room-A'); + }); + + it('should return empty array when no users are online', async () => { + mockAdapter.getOnlineUserIds.mockResolvedValue([]); + + const result = await controller.getOnlineUsers(); + + expect(result).toEqual({ onlineUserIds: [] }); + }); + + it('should propagate adapter errors', async () => { + mockAdapter.getOnlineUserIds.mockRejectedValue(new Error('adapter error')); + + await expect(controller.getOnlineUsers()).rejects.toThrow('adapter error'); + }); + }); +}); + diff --git a/libs/dynamic-api/src/modules/presence/presence.controller.ts b/libs/dynamic-api/src/modules/presence/presence.controller.ts new file mode 100644 index 0000000..ad1df58 --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/presence.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Get, Inject, Query } from '@nestjs/common'; +import { ApiOkResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; +import { Public } from '../../decorators'; +import { + DYNAMIC_API_PRESENCE_ADAPTER, + PresenceAdapter, + PresenceResponse, +} from '../../interfaces'; + +/** + * Optional HTTP endpoint that exposes the current presence state. + * + * Enabled by passing `enableController: true` to `DynamicApiPresenceModule.register()`. + * + * The route is decorated with `@Public()` so it remains accessible even when the + * `DynamicApiJwtAuthGuard` global guard is active. + * + * Routes: + * - `GET /presence` — returns all online user IDs. + * - `GET /presence?room=xxx` — returns online user IDs in a specific room. + */ +@ApiTags('Presence') +@Controller('presence') +export class PresenceController { + constructor( + @Inject(DYNAMIC_API_PRESENCE_ADAPTER) + private readonly presenceAdapter: PresenceAdapter, + ) {} + + @Public() + @Get() + @ApiOperation({ summary: 'Get online user IDs (optionally filtered by room)' }) + @ApiQuery({ name: 'room', required: false, type: String }) + @ApiOkResponse({ + description: 'List of online user IDs.', + schema: { + type: 'object', + properties: { + onlineUserIds: { type: 'array', items: { type: 'string' } }, + }, + }, + }) + async getOnlineUsers(@Query('room') room?: string): Promise { + const onlineUserIds = await this.presenceAdapter.getOnlineUserIds(room); + return { onlineUserIds }; + } +} + diff --git a/libs/dynamic-api/src/modules/presence/presence.gateway.spec.ts b/libs/dynamic-api/src/modules/presence/presence.gateway.spec.ts new file mode 100644 index 0000000..0243bcc --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/presence.gateway.spec.ts @@ -0,0 +1,223 @@ +import { createPresenceGateway } from './presence.gateway'; +import { DynamicApiWsConfigStore } from '../../helpers/ws-config.store'; + +describe('createPresenceGateway', () => { + const mockPresenceAdapter = { + setOnline: jest.fn(), + setOffline: jest.fn(), + getSocketCount: jest.fn(), + isOnline: jest.fn(), + getOnlineUserIds: jest.fn(), + }; + + let gateway: InstanceType>; + let mockServer: { + on: jest.Mock; + emit: jest.Mock; + }; + let connectionHandler: (socket: Record) => void; + + beforeEach(() => { + jest.clearAllMocks(); + DynamicApiWsConfigStore.reset(); + + mockPresenceAdapter.setOnline.mockResolvedValue(undefined); + mockPresenceAdapter.setOffline.mockResolvedValue(undefined); + mockPresenceAdapter.getSocketCount.mockResolvedValue(0); + + mockServer = { + on: jest.fn((event: string, handler: (s: Record) => void) => { + if (event === 'connection') { + connectionHandler = handler; + } + }), + emit: jest.fn(), + }; + + const GatewayClass = createPresenceGateway({}); + gateway = new GatewayClass(mockPresenceAdapter as never); + }); + + it('should create a gateway class', () => { + expect(gateway).toBeTruthy(); + }); + + it('should inject the DYNAMIC_API_PRESENCE_ADAPTER', () => { + expect(gateway.presenceAdapter).toBe(mockPresenceAdapter); + }); + + describe('afterInit', () => { + it('should register a connection listener on the server', () => { + gateway.afterInit(mockServer as never); + expect(mockServer.on).toHaveBeenCalledWith('connection', expect.any(Function)); + }); + }); + + describe('connection handling', () => { + beforeEach(() => { + gateway.afterInit(mockServer as never); + }); + + it('should call setOnline and emit user:online when an authenticated socket connects', async () => { + const socket = { + id: 'sock-1', + user: { id: 'user-1' }, + on: jest.fn(), + }; + + gateway.onSocketConnection(mockServer as never, socket as never); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockPresenceAdapter.setOnline).toHaveBeenCalledWith('user-1', 'sock-1'); + expect(mockServer.emit).toHaveBeenCalledWith('user:online', { userId: 'user-1' }); + }); + + it('should register a disconnect listener after setOnline resolves', async () => { + const socket = { + id: 'sock-1', + user: { id: 'user-1' }, + on: jest.fn(), + }; + + gateway.onSocketConnection(mockServer as never, socket as never); + await new Promise((r) => setTimeout(r, 10)); + + expect(socket.on).toHaveBeenCalledWith('disconnect', expect.any(Function)); + }); + + it('should silently ignore anonymous sockets (no user)', async () => { + const socket = { id: 'sock-anon', on: jest.fn() }; + + gateway.onSocketConnection(mockServer as never, socket as never); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockPresenceAdapter.setOnline).not.toHaveBeenCalled(); + expect(mockServer.emit).not.toHaveBeenCalled(); + }); + + it('should log on connection when debug is enabled', async () => { + DynamicApiWsConfigStore.debug = true; + const spyLog = jest.spyOn(gateway.logger, 'log').mockImplementation(() => {}); + const socket = { id: 'sock-1', user: { id: 'user-1' }, on: jest.fn() }; + + gateway.onSocketConnection(mockServer as never, socket as never); + await new Promise((r) => setTimeout(r, 10)); + + expect(spyLog).toHaveBeenCalledWith(expect.stringContaining('user:online')); + }); + }); + + describe('disconnect handling (onSocketDisconnect)', () => { + it('should call setOffline on disconnect', async () => { + gateway.onSocketDisconnect(mockServer as never, 'user-1', 'sock-1'); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockPresenceAdapter.setOffline).toHaveBeenCalledWith('user-1', 'sock-1'); + }); + + it('should emit user:offline when no sockets remain', async () => { + mockPresenceAdapter.getSocketCount.mockResolvedValue(0); + gateway.onSocketDisconnect(mockServer as never, 'user-1', 'sock-1'); + await new Promise((r) => setTimeout(r, 10)); + + expect(mockServer.emit).toHaveBeenCalledWith('user:offline', { userId: 'user-1' }); + }); + + it('should NOT emit user:offline when other sockets still exist (multi-tab)', async () => { + mockPresenceAdapter.getSocketCount.mockResolvedValue(2); + gateway.onSocketDisconnect(mockServer as never, 'user-1', 'sock-1'); + await new Promise((r) => setTimeout(r, 10)); + + const offlineCalls = (mockServer.emit as jest.Mock).mock.calls.filter( + ([event]) => event === 'user:offline', + ); + expect(offlineCalls).toHaveLength(0); + }); + + it('should log on disconnect when debug is enabled', async () => { + DynamicApiWsConfigStore.debug = true; + const spyLog = jest.spyOn(gateway.logger, 'log').mockImplementation(() => {}); + mockPresenceAdapter.getSocketCount.mockResolvedValue(0); + + gateway.onSocketDisconnect(mockServer as never, 'user-1', 'sock-1'); + await new Promise((r) => setTimeout(r, 10)); + + expect(spyLog).toHaveBeenCalledWith(expect.stringContaining('user:offline')); + }); + + it('should log error string when disconnect handler throws a non-Error value', async () => { + mockPresenceAdapter.setOffline.mockRejectedValue('string-error'); + const spyError = jest.spyOn(gateway.logger, 'error').mockImplementation(() => {}); + + gateway.onSocketDisconnect(mockServer as never, 'user-1', 'sock-1'); + await new Promise((r) => setTimeout(r, 10)); + + expect(spyError).toHaveBeenCalledWith(expect.stringContaining('disconnect handler error')); + }); + + it('should log error when setOffline rejects with an Error instance', async () => { + mockPresenceAdapter.setOffline.mockRejectedValue(new Error('redis down')); + const spyError = jest.spyOn(gateway.logger, 'error').mockImplementation(() => {}); + + gateway.onSocketDisconnect(mockServer as never, 'user-1', 'sock-1'); + await new Promise((r) => setTimeout(r, 10)); + + expect(spyError).toHaveBeenCalledWith(expect.stringContaining('redis down')); + }); + }); + + describe('afterInit integration', () => { + it('should call onSocketConnection when server emits a connection event', () => { + gateway.afterInit(mockServer as never); + const spyConnect = jest.spyOn(gateway, 'onSocketConnection').mockImplementation(() => {}); + + const socket = { id: 'sock-x', user: { id: 'u-x' }, on: jest.fn() }; + connectionHandler(socket as never); + + expect(spyConnect).toHaveBeenCalledWith(mockServer, socket); + }); + }); + + describe('disconnect registration via onSocketConnection', () => { + it('should register a disconnect listener that calls onSocketDisconnect', async () => { + let disconnectCb: (() => void) | undefined; + const socket = { + id: 'sock-1', + user: { id: 'user-1' }, + on: jest.fn((event: string, cb: () => void) => { + if (event === 'disconnect') disconnectCb = cb; + }), + }; + + gateway.afterInit(mockServer as never); + gateway.onSocketConnection(mockServer as never, socket as never); + await new Promise((r) => setTimeout(r, 10)); + + expect(disconnectCb).toBeDefined(); + + const spyDisconnect = jest.spyOn(gateway, 'onSocketDisconnect').mockImplementation(() => {}); + disconnectCb!(); + + expect(spyDisconnect).toHaveBeenCalledWith(mockServer, 'user-1', 'sock-1'); + }); + }); + + describe('createPresenceGateway with options', () => { + it('should accept custom GatewayOptions without throwing', () => { + expect(() => createPresenceGateway({ namespace: '/custom' })).not.toThrow(); + }); + + it('should use empty object as default options (no arguments)', () => { + expect(() => createPresenceGateway()).not.toThrow(); + }); + }); +}); + + + + + + + + + diff --git a/libs/dynamic-api/src/modules/presence/presence.gateway.ts b/libs/dynamic-api/src/modules/presence/presence.gateway.ts new file mode 100644 index 0000000..f3322c3 --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/presence.gateway.ts @@ -0,0 +1,99 @@ +import { Inject } from '@nestjs/common'; +import { OnGatewayInit, WebSocketGateway, WebSocketServer } from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; +import { DynamicApiWsConfigStore } from '../../helpers'; +import { + DYNAMIC_API_PRESENCE_ADAPTER, + ExtendedSocket, + GatewayOptions, + PresenceAdapter, + PresenceEventPayload, +} from '../../interfaces'; +import { MongoDBDynamicApiLogger } from '../../logger'; + +/** + * Factory that creates a `PresenceGateway` bound to the provided WebSocket options. + * + * The gateway hooks into the socket.io server `connection` event (via `afterInit`) + * to track user presence. It: + * 1. Calls `presenceAdapter.setOnline(userId, socketId)` on connect. + * 2. Emits `user:online` to all connected clients. + * 3. On disconnect calls `presenceAdapter.setOffline(userId, socketId)`. + * 4. Emits `user:offline` when the user's last socket disconnects. + * + * Anonymous connections (no `socket.user`) are silently ignored. + * + * Uses the **same** gateway options (port / namespace) as the rest of the + * DynamicApi gateways (Option B: shared namespace). + */ +export function createPresenceGateway(options: GatewayOptions = {}) { + @WebSocketGateway(options) + class PresenceGateway implements OnGatewayInit { + readonly logger = new MongoDBDynamicApiLogger('PresenceGateway'); + + @WebSocketServer() + server: Server; + + constructor( + @Inject(DYNAMIC_API_PRESENCE_ADAPTER) + readonly presenceAdapter: PresenceAdapter, + ) {} + + afterInit(server: Server): void { + server.on('connection', (socket: Socket) => { + this.onSocketConnection(server, socket as ExtendedSocket); + }); + } + + onSocketConnection(server: Server, socket: ExtendedSocket): void { + const userId = socket.user?.id; + + if (!userId) { + return; + } + + this.presenceAdapter.setOnline(userId, socket.id).then(() => { + const payload: PresenceEventPayload = { userId }; + server.emit('user:online', payload); + + if (DynamicApiWsConfigStore.debug) { + this.logger.log( + `[Presence] user:online – userId=${userId}, socketId=${socket.id}`, + ); + } + + socket.on('disconnect', () => { + this.onSocketDisconnect(server, userId, socket.id); + }); + }); + } + + onSocketDisconnect( + server: Server, + userId: string, + socketId: string, + ): void { + this.presenceAdapter + .setOffline(userId, socketId) + .then(() => this.presenceAdapter.getSocketCount(userId)) + .then((count) => { + if (DynamicApiWsConfigStore.debug) { + this.logger.log( + `[Presence] user:offline – userId=${userId}, socketId=${socketId}, remainingSockets=${count}`, + ); + } + + if (count === 0) { + const payload: PresenceEventPayload = { userId }; + server.emit('user:offline', payload); + } + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : JSON.stringify(err); + this.logger.error(`[Presence] disconnect handler error: ${message}`); + }); + } + } + + return PresenceGateway; +} diff --git a/libs/dynamic-api/src/modules/presence/presence.module.spec.ts b/libs/dynamic-api/src/modules/presence/presence.module.spec.ts new file mode 100644 index 0000000..2570fc9 --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/presence.module.spec.ts @@ -0,0 +1,131 @@ +import { DynamicApiModule } from '../../dynamic-api.module'; +import { InMemoryPresenceAdapter } from './adapters/in-memory-presence.adapter'; +import { RedisPresenceAdapter } from './adapters/redis-presence.adapter'; +import { DynamicApiPresenceModule } from './presence.module'; + +jest.mock('../../dynamic-api.module', () => ({ + DynamicApiModule: { state: { get: jest.fn() } }, +})); + +jest.mock('./adapters/redis-presence.adapter', () => ({ + RedisPresenceAdapter: jest.fn().mockImplementation(() => ({ type: 'redis' })), +})); + +jest.mock('./adapters/in-memory-presence.adapter', () => ({ + InMemoryPresenceAdapter: jest.fn().mockImplementation(() => ({ type: 'memory' })), +})); + +jest.mock('./presence.gateway', () => ({ + createPresenceGateway: jest.fn().mockReturnValue( + class MockPresenceGateway {}, + ), +})); + +const mockStateGet = DynamicApiModule.state.get as jest.Mock; + +describe('DynamicApiPresenceModule', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockStateGet.mockReturnValue(undefined); + }); + + describe('register', () => { + describe('adapter: memory', () => { + it('should create module with InMemoryPresenceAdapter', () => { + const result = DynamicApiPresenceModule.register({ adapter: 'memory' }); + + expect(InMemoryPresenceAdapter).toHaveBeenCalledTimes(1); + expect(RedisPresenceAdapter).not.toHaveBeenCalled(); + expect(result.module).toBe(DynamicApiPresenceModule); + }); + + it('should NOT include PresenceController when enableController is false (default)', () => { + const result = DynamicApiPresenceModule.register({ adapter: 'memory' }); + + expect(result.controllers).toEqual([]); + }); + + it('should include PresenceController when enableController is true', () => { + const result = DynamicApiPresenceModule.register({ + adapter: 'memory', + enableController: true, + }); + + expect(result.controllers).toHaveLength(1); + }); + + it('should export DYNAMIC_API_PRESENCE_ADAPTER token', () => { + const result = DynamicApiPresenceModule.register({ adapter: 'memory' }); + + expect(result.exports).toHaveLength(1); + expect(typeof result.exports![0]).toBe('symbol'); + }); + }); + + describe('adapter: redis', () => { + it('should create module with RedisPresenceAdapter using provided URL', () => { + DynamicApiPresenceModule.register({ + adapter: 'redis', + redisUrl: 'redis://localhost:6379', + }); + + expect(RedisPresenceAdapter).toHaveBeenCalledWith('redis://localhost:6379', undefined); + }); + + it('should pass custom TTL to RedisPresenceAdapter', () => { + DynamicApiPresenceModule.register({ + adapter: 'redis', + redisUrl: 'redis://localhost:6379', + redisTtlSeconds: 120, + }); + + expect(RedisPresenceAdapter).toHaveBeenCalledWith('redis://localhost:6379', 120); + }); + + it('should throw when redisUrl is missing', () => { + expect(() => + DynamicApiPresenceModule.register({ adapter: 'redis' }), + ).toThrow('`redisUrl` is required'); + }); + }); + + describe('gateway options', () => { + it('should use gatewayOptions from DynamicApiModule state', () => { + const gatewayOpts = { namespace: '/test' }; + mockStateGet.mockImplementation((key: string) => { + if (key === 'gatewayOptions') return gatewayOpts; + return undefined; + }); + + const { createPresenceGateway } = require('./presence.gateway'); + DynamicApiPresenceModule.register({ adapter: 'memory' }); + + expect(createPresenceGateway).toHaveBeenCalledWith(gatewayOpts); + }); + + it('should fall back to broadcastGatewayOptions when gatewayOptions is absent', () => { + const broadcastOpts = { namespace: '/broadcast' }; + mockStateGet.mockImplementation((key: string) => { + if (key === 'broadcastGatewayOptions') return broadcastOpts; + return undefined; + }); + + const { createPresenceGateway } = require('./presence.gateway'); + DynamicApiPresenceModule.register({ adapter: 'memory' }); + + expect(createPresenceGateway).toHaveBeenCalledWith(broadcastOpts); + }); + + it('should fall back to empty object when no gateway options exist', () => { + mockStateGet.mockReturnValue(undefined); + const { createPresenceGateway } = require('./presence.gateway'); + + DynamicApiPresenceModule.register({ adapter: 'memory' }); + + expect(createPresenceGateway).toHaveBeenCalledWith({}); + }); + }); + }); +}); + + diff --git a/libs/dynamic-api/src/modules/presence/presence.module.ts b/libs/dynamic-api/src/modules/presence/presence.module.ts new file mode 100644 index 0000000..0dae08c --- /dev/null +++ b/libs/dynamic-api/src/modules/presence/presence.module.ts @@ -0,0 +1,77 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { + DYNAMIC_API_PRESENCE_ADAPTER, + PresenceRegisterOptions, +} from '../../interfaces'; +import { DynamicApiModule } from '../../dynamic-api.module'; +import { InMemoryPresenceAdapter } from './adapters/in-memory-presence.adapter'; +import { RedisPresenceAdapter } from './adapters/redis-presence.adapter'; +import { PresenceController } from './presence.controller'; +import { createPresenceGateway } from './presence.gateway'; + +/** + * Standalone NestJS module providing WebSocket presence tracking. + * + * ### Usage + * ```typescript + * // In-memory (single instance / dev) + * DynamicApiPresenceModule.register({ adapter: 'memory' }) + * + * // Redis (multi-instance / production) with custom TTL + * DynamicApiPresenceModule.register({ + * adapter: 'redis', + * redisUrl: 'redis://localhost:6379', + * redisTtlSeconds: 90, + * enableController: true, + * }) + * ``` + * + * Imported in `AppModule` alongside `DynamicApiModule.forRoot(...)`. + * The `PresenceGateway` shares the same WebSocket options as existing gateways + * (same namespace / port) so no second socket server is spun up (Option B). + */ +@Module({}) +export class DynamicApiPresenceModule { + static register(options: PresenceRegisterOptions): DynamicModule { + const { + adapter, + redisUrl, + redisTtlSeconds, + enableController = false, + } = options; + + if (adapter === 'redis' && !redisUrl) { + throw new Error( + 'DynamicApiPresenceModule: `redisUrl` is required when adapter is "redis".', + ); + } + + const adapterInstance = + adapter === 'redis' + ? new RedisPresenceAdapter(redisUrl!, redisTtlSeconds) + : new InMemoryPresenceAdapter(); + + const adapterProvider = { + provide: DYNAMIC_API_PRESENCE_ADAPTER, + useValue: adapterInstance, + }; + + const gatewayOptions = + DynamicApiModule.state.get('gatewayOptions') ?? + DynamicApiModule.state.get('broadcastGatewayOptions') ?? + {}; + + const GatewayClass = createPresenceGateway(gatewayOptions); + + return { + module: DynamicApiPresenceModule, + providers: [ + adapterProvider, + { provide: GatewayClass, useClass: GatewayClass }, + ], + controllers: enableController ? [PresenceController] : [], + exports: [DYNAMIC_API_PRESENCE_ADAPTER], + }; + } +} + diff --git a/libs/dynamic-api/src/version.json b/libs/dynamic-api/src/version.json index 2d0c429..c362453 100644 --- a/libs/dynamic-api/src/version.json +++ b/libs/dynamic-api/src/version.json @@ -1,3 +1,3 @@ { - "version": "4.8.0" + "version": "4.9.0" } diff --git a/libs/dynamic-api/test/for-feature/presence.e2e-spec.ts b/libs/dynamic-api/test/for-feature/presence.e2e-spec.ts new file mode 100644 index 0000000..26d5bf5 --- /dev/null +++ b/libs/dynamic-api/test/for-feature/presence.e2e-spec.ts @@ -0,0 +1,266 @@ +import { INestApplication } from '@nestjs/common'; +import { Prop, Schema } from '@nestjs/mongoose'; +import { Test } from '@nestjs/testing'; +import { io, Socket } from 'socket.io-client'; +import mongoose from 'mongoose'; +import 'dotenv/config'; +import { + BaseEntity, + DynamicApiModule, + DynamicApiPresenceModule, + enableDynamicAPIWebSockets, +} from '../../src'; +import { closeTestingApp, createTestingApp, server } from '../e2e.setup'; + +// --------------------------------------------------------------------------- +// Entity +// --------------------------------------------------------------------------- + +@Schema({ collection: 'presence-users' }) +class PresenceUserEntity extends BaseEntity { + @Prop({ type: String, required: true }) + email: string; + + @Prop({ type: String, required: true }) + password: string; +} + +// --------------------------------------------------------------------------- +// Socket helpers +// --------------------------------------------------------------------------- + +const DEFAULT_TIMEOUT_MS = 6000; + +/** Connect a socket.io client and wait until connected. */ +async function connectSocket( + baseUrl: string, + accessToken?: string, +): Promise { + return new Promise((resolve, reject) => { + const socket = io(baseUrl, { + query: accessToken ? { accessToken } : {}, + reconnection: false, + }); + + const timeout = setTimeout(() => { + socket.close(); + reject(new Error('Socket connection timed out')); + }, DEFAULT_TIMEOUT_MS); + + socket.once('connect', () => { + clearTimeout(timeout); + resolve(socket); + }); + + socket.once('connect_error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); +} + +/** Wait for a specific event on a socket, with a timeout. */ +function waitForEvent( + socket: Socket, + event: string, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`Timeout waiting for "${event}" after ${timeoutMs}ms`)), + timeoutMs, + ); + + socket.once(event, (data: T) => { + clearTimeout(timeout); + resolve(data); + }); + }); +} + +/** Wait a fixed number of milliseconds. */ +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// --------------------------------------------------------------------------- +// Suite +// --------------------------------------------------------------------------- + +describe('DynamicApiPresenceModule (e2e)', () => { + // App init + socket handshakes can exceed the default 5 s jest timeout. + jest.setTimeout(20000); + + let accessToken: string; + + beforeEach(() => { + DynamicApiModule.state['resetState'](); + }); + + afterEach(async () => { + await closeTestingApp(mongoose.connections); + }); + + // ── Shared app init ──────────────────────────────────────────────────── + + async function initPresenceApp(enableController = false): Promise { + const uri = process.env.MONGO_DB_URL!; + + const moduleRef = await Test.createTestingModule({ + imports: [ + DynamicApiModule.forRoot(uri, { + useAuth: { + userEntity: PresenceUserEntity, + jwt: { secret: 'presence-e2e-secret', expiresIn: '1h' }, + login: {}, + webSocket: true, + }, + }), + DynamicApiPresenceModule.register({ + adapter: 'memory', + enableController, + }), + ], + }).compile(); + + return createTestingApp( + moduleRef, + undefined, + async (app: INestApplication) => { + enableDynamicAPIWebSockets(app); + }, + ); + } + + async function registerAndLogin( + email = 'presence@test.co', + password = 'P@ssw0rd!', + ): Promise { + await server.post<{ email: string; password: string }>( + '/auth/register', + { email, password }, + ); + + const { body } = await server.post< + { email: string; password: string }, + { body: { accessToken: string } } + >('/auth/login', { email, password }); + + return (body as unknown as { accessToken: string }).accessToken; + } + + // ── Tests ────────────────────────────────────────────────────────────── + + it('should emit user:online when an authenticated socket connects', async () => { + await initPresenceApp(); + accessToken = await registerAndLogin(); + + const observer = await connectSocket(global.appBaseUrl!); + const onlinePromise = waitForEvent<{ userId: string }>(observer, 'user:online'); + + const authenticated = await connectSocket(global.appBaseUrl!, accessToken); + + const payload = await onlinePromise; + expect(payload).toMatchObject({ userId: expect.any(String) }); + + authenticated.disconnect(); + observer.disconnect(); + }); + + it('should emit user:offline when the last authenticated socket disconnects', async () => { + await initPresenceApp(); + accessToken = await registerAndLogin(); + + const observer = await connectSocket(global.appBaseUrl!); + + // Register the user:online listener BEFORE the authenticated socket connects + // so the event is never missed (no race condition). + const onlinePromise = waitForEvent<{ userId: string }>(observer, 'user:online', 5000); + const authenticated = await connectSocket(global.appBaseUrl!, accessToken); + + // Wait until presence is established before testing the offline path. + await onlinePromise; + + const offlinePromise = waitForEvent<{ userId: string }>(observer, 'user:offline', 5000); + authenticated.disconnect(); + + const payload = await offlinePromise; + expect(payload).toMatchObject({ userId: expect.any(String) }); + + observer.disconnect(); + }); + + it('should NOT emit user:offline while the user has another active socket (multi-tab)', async () => { + await initPresenceApp(); + accessToken = await registerAndLogin(); + + const observer = await connectSocket(global.appBaseUrl!); + const tab1 = await connectSocket(global.appBaseUrl!, accessToken); + const tab2 = await connectSocket(global.appBaseUrl!, accessToken); + + // Let both online events settle + await delay(300); + + let offlineReceived = false; + observer.on('user:offline', () => { offlineReceived = true; }); + + tab1.disconnect(); + await delay(500); + + expect(offlineReceived).toBe(false); + + tab2.disconnect(); + observer.disconnect(); + }); + + it('should NOT emit user:online for anonymous (unauthenticated) connections', async () => { + await initPresenceApp(); + + const observer = await connectSocket(global.appBaseUrl!); + + let onlineReceived = false; + observer.on('user:online', () => { onlineReceived = true; }); + + const anon = await connectSocket(global.appBaseUrl!); + await delay(300); + + expect(onlineReceived).toBe(false); + + anon.disconnect(); + observer.disconnect(); + }); + + it('GET /presence should return online user IDs when enableController is true', async () => { + await initPresenceApp(true); + accessToken = await registerAndLogin(); + + const observer = await connectSocket(global.appBaseUrl!); + + // Register the listener BEFORE the authenticated socket connects so the + // event is guaranteed to be captured (no race condition). + const onlinePromise = waitForEvent<{ userId: string }>(observer, 'user:online', 5000); + const authenticated = await connectSocket(global.appBaseUrl!, accessToken); + + // Wait until presence is established — this proves setOnline has completed. + await onlinePromise; + + const res = await server.get('/presence'); + const onlineIds: string[] = ( + res as unknown as { body: { onlineUserIds: string[] } } + ).body?.onlineUserIds ?? []; + + expect(onlineIds.length).toBeGreaterThan(0); + + authenticated.disconnect(); + observer.disconnect(); + }); + + it('GET /presence?room=nonexistent should return empty array', async () => { + await initPresenceApp(true); + + const res = await server.get('/presence', { query: { room: 'nonexistent-room' } }); + const onlineIds: string[] = ( + res as unknown as { body: { onlineUserIds: string[] } } + ).body?.onlineUserIds ?? []; + + expect(onlineIds).toEqual([]); + }); +}); diff --git a/package-lock.json b/package-lock.json index 45ef0a8..8b9f136 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "mongodb-dynamic-api", - "version": "4.8.0", + "version": "4.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mongodb-dynamic-api", - "version": "4.8.0", + "version": "4.9.0", "license": "MIT", "dependencies": { "@nestjs/cache-manager": "^3.0.1", @@ -26,6 +26,7 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", + "ioredis": "^5.10.1", "mongodb-pipeline-builder": "^4.0.2", "mongoose": "^8.9.5", "passport": "^0.7.0", @@ -1460,6 +1461,12 @@ } } }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -6259,6 +6266,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -7024,6 +7040,15 @@ "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -9149,6 +9174,30 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -11563,6 +11612,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.escaperegexp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", @@ -11575,6 +11630,12 @@ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -13526,6 +13587,27 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.1.tgz", @@ -15024,6 +15106,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/package.json b/package.json index 7c759a5..8d94134 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mongodb-dynamic-api", - "version": "4.8.0", + "version": "4.9.0", "description": "Auto generated CRUD API for MongoDB using NestJS", "readmeFilename": "README.md", "main": "index.js", @@ -57,6 +57,7 @@ "class-validator": "^0.14.1", "cookie-parser": "^1.4.7", "dotenv": "^16.4.5", + "ioredis": "^5.10.1", "mongodb-pipeline-builder": "^4.0.2", "mongoose": "^8.9.5", "passport": "^0.7.0",