import { Logger, type OnModuleDestroy } from '@nestjs/common'; import { Redis } from 'ioredis'; import type { PresencePort } from './presence.port'; /** * Redis-backed presence for multi-replica prod: a socket connected to replica A and a message * projected on replica B must share the same "who is viewing what" view, which an in-memory Map * cannot provide. Wired (in place of {@link PresenceService}) only when REDIS_URL is set. * * Layout (per user, tiny): * sock:{socketId} -> userId (so clearSocket can find the user), EX TTL * user:{userId} -> HASH socketId=threadId, EX TTL * isViewing = does the user hash hold this threadId for any socket. Keys carry a TTL so a crashed * replica's entries self-heal; expiry errs toward "not viewing" (push is sent), the safe default. */ export class RedisPresenceService implements PresencePort, OnModuleDestroy { private readonly logger = new Logger(RedisPresenceService.name); private readonly redis: Redis; private static readonly TTL_SECONDS = 300; private static readonly PREFIX = 'iios:presence'; constructor(url: string, client?: Redis) { this.redis = client ?? new Redis(url, { maxRetriesPerRequest: null }); this.redis.on('error', (err) => this.logger.error(`redis presence error: ${err.message}`)); } private sockKey(socketId: string): string { return `${RedisPresenceService.PREFIX}:sock:${socketId}`; } private userKey(userId: string): string { return `${RedisPresenceService.PREFIX}:user:${userId}`; } async setFocus(socketId: string, userId: string, threadId: string | null): Promise { const ttl = RedisPresenceService.TTL_SECONDS; const sock = this.sockKey(socketId); const user = this.userKey(userId); const pipe = this.redis.multi().set(sock, userId, 'EX', ttl); if (threadId === null) { // Blurred / no thread open — the socket stays connected but is viewing nothing. pipe.hdel(user, socketId); } else { pipe.hset(user, socketId, threadId).expire(user, ttl); } await pipe.exec(); } async clearSocket(socketId: string): Promise { const sock = this.sockKey(socketId); const userId = await this.redis.get(sock); const pipe = this.redis.multi().del(sock); if (userId) pipe.hdel(this.userKey(userId), socketId); await pipe.exec(); } async isViewing(userId: string, threadId: string): Promise { const threads = await this.redis.hvals(this.userKey(userId)); return threads.includes(threadId); } async onModuleDestroy(): Promise { await this.redis.quit().catch(() => undefined); } }