Módulo Chat
src/app/modules/chat/
Gateway WebSocket que gestiona la mensajería en tiempo real. Es el módulo más complejo del sistema.
Stack
- Socket.IO en el namespace
/chat - Salas en memoria (
ChatService) vinculadas opcionalmente a unTicketen MongoDB - Sin estado persistente de sala — si el servidor reinicia, las salas en memoria se pierden
Tipos de usuario
type | Descripción | Datos requeridos al autenticar |
|---|---|---|
contact | Cliente externo que inicia la conversación | id (externalId numérico) — se busca en la API externa y se upserta en MongoDB |
user | Agente/operador del CRM | id (externalId numérico) — se busca en la API externa y se resuelve contra User.externalId |
system | Proceso automatizado | ninguno adicional |
Archivos
| Archivo | Descripción |
|---|---|
chat.module.ts | Importa TenantModule, ContactModule, TicketModule, MessageModule, FileModule |
gateways/chat.gateway.ts | Gateway principal con todos los event handlers |
gateways/chat.gateway.spec.ts | 48 tests del gateway (unitarios) |
services/chat.service.ts | Gestión de salas y mensajes en memoria |
services/chat.service.spec.ts | 26 tests del servicio |
filters/ws-exception.filter.ts | Filtro de errores para WebSocket |
interfaces/chat.interfaces.ts | AuthSocket, ConnectedUser, Lang |
dtos/ | DTOs de validación para cada evento |
Cobertura de tests
Test Suites: 34 passed (toda la suite del backend)
Tests: 474 passed
Para ejecutar solo los tests del módulo chat:
npx jest "chat" --no-coverage
# → 74 tests pasando (gateway + servicio)
Eventos WebSocket
Cliente → Servidor
| Evento | DTO | Descripción |
|---|---|---|
authenticate | AuthenticateDto | Autenticación con token de tenant |
create-room | — | Crear sala nueva en memoria |
join-room | JoinRoomDto | Unirse a sala existente |
send-message | SendMessageDto | Enviar mensaje de texto |
send-file | SendFileDto | Enviar archivo (requiere fileId previo) |
mark-read | MarkReadDto | Marcar mensajes del ticket como leídos |
get-rooms | — | Listar salas activas del usuario |
get-messages | JoinRoomDto | Historial en memoria de una sala |
leave-room | JoinRoomDto | Salir del room de Socket.IO |
Servidor → Cliente
| Evento | Descripción |
|---|---|
authenticated | Respuesta exitosa a authenticate |
room-created | Sala creada (al creador) |
room-joined | Confirmación de unión a sala |
user-joined | Broadcast a otros participantes cuando alguien se une |
room-left | Confirmación de salida |
user-left | Broadcast a participantes restantes cuando alguien sale |
new-message | Nuevo mensaje de texto en la sala |
new-file-message | Nuevo mensaje con archivo en la sala |
messages-read | Broadcast cuando mensajes son marcados como leídos |
rooms-list | Lista de salas del usuario |
messages-list | Lista de mensajes en memoria de una sala |
exception | Error emitido al cliente |
DTOs
AuthenticateDto
{
type: 'contact' | 'user' | 'system'
token: string // token del Tenant (ver colección Tenant.tokens)
lang?: 'es' | 'en' // defecto: 'es'
id?: number // requerido para type = 'contact' | 'user' (externalId numérico)
}
type: 'contact'— El gateway llama a la API externa con{ token, type: 'contact', id }. Si el contacto no existe en MongoDB, se crea. Si existe, se reutiliza.
type: 'user'— El gateway llama a la API externa con{ token, type: 'user', id }. Se intenta resolver elUseren MongoDB porexternalId. Si existe, su_idqueda en sesión; si no existe, la sesión funciona igual (sinuserIden DB).
type: 'system'— No requiereid. No hay verificación externa.
JoinRoomDto
{
roomId: string // UUID de la sala en memoria
}
SendMessageDto
{
roomId: string
content: string
}
SendFileDto
{
roomId: string
fileId: string // ObjectId del File previamente subido vía REST
}
MarkReadDto
{
ticketId: string // ObjectId del Ticket en MongoDB
}
Quick-start (ejemplo de conexión)
Los datos de prueba están en socket.json en la raíz del monorepo.
import { io } from 'socket.io-client';
const TOKEN = '2817734d7e5fabba0d094cde03a8cbbe08542a1bfec6b54cbaee7a932fd4b8a8';
// ── Usuario (agente CRM) ──────────────────────────────────────────────────────
const user = io('http://localhost:3000/chat');
user.on('connect', () => {
user.emit('authenticate', {
type: 'user',
token: TOKEN,
id: 31912, // externalId numérico del usuario en el sistema externo
});
});
user.on('authenticated', (data) => console.log('usuario autenticado', data));
// El gateway llama a la API externa con { token, type: 'user', id: 31912 }
// Si hay un User con externalId='31912' en MongoDB → su _id queda en la sesión
// Si no existe → sesión activa igual, sin userId en DB
// ── Contacto (se crea si no existe) ──────────────────────────────────────────
const contact = io('http://localhost:3000/chat');
contact.on('connect', () => {
contact.emit('authenticate', {
type: 'contact',
token: TOKEN,
id: 31914, // externalId numérico del contacto
});
});
contact.on('authenticated', (data) => console.log('contacto autenticado', data));
// El gateway llama a la API externa con { token, type: 'contact', id: 31914 }
// Si el contacto no existe en MongoDB → se crea
// Si ya existe → se reutiliza
Salas
// ── Crear sala (típicamente lo hace el contacto) ──────────────────────────────
contact.emit('create-room');
contact.on('room-created', ({ roomId, ticketId, participants, createdAt }) => {
console.log('Sala creada:', roomId);
// Si quien crea es un contact → el gateway busca o crea un Ticket en MongoDB
// ticketId estará presente en ese caso
});
// ── Unirse a sala (típicamente lo hace el usuario CRM) ───────────────────────
user.emit('join-room', { roomId: 'UUID-DE-LA-SALA' });
user.on('room-joined', ({ roomId, ticketId, participants, createdAt }) => {
console.log('Unido a sala:', roomId);
});
// El otro participante recibe notificación
contact.on('user-joined', ({ socketId, roomId }) => {
console.log('Alguien se unió:', socketId);
});
// ── Listar mis salas activas ──────────────────────────────────────────────────
user.emit('get-rooms');
user.on('rooms-list', ({ rooms }) => {
console.log('Mis salas:', rooms);
// rooms: Room[] — solo las salas donde el socket es participante y pertenecen al tenant
});
// ── Salir de una sala ─────────────────────────────────────────────────────────
user.emit('leave-room', { roomId: 'UUID-DE-LA-SALA' });
user.on('room-left', ({ roomId }) => {
console.log('Salí de:', roomId);
});
// Los demás participantes reciben:
contact.on('user-left', ({ socketId, roomId }) => {
console.log('Participante salió:', socketId);
});
Mensajes
// ── Enviar texto ──────────────────────────────────────────────────────────────
contact.emit('send-message', {
roomId: 'UUID-DE-LA-SALA',
content: 'Hola, necesito ayuda con mi pedido.',
});
// Todos los participantes de la sala reciben:
user.on('new-message', (msg) => {
console.log(msg);
// msg sin ticket (sala efímera):
// { id, roomId, senderId, senderName, content, timestamp }
//
// msg con ticket (sala vinculada a un Ticket en MongoDB):
// { id, roomId, senderId, senderName, content, timestamp, dbId, ticketId }
// dbId → ObjectId del Message persistido en MongoDB
// ticketId → ObjectId del Ticket vinculado a la sala
});
// ── Enviar archivo ────────────────────────────────────────────────────────────
// El archivo DEBE subirse primero vía REST: POST /api/v1/files/upload
// La respuesta de ese endpoint devuelve el fileId (ObjectId)
user.emit('send-file', {
roomId: 'UUID-DE-LA-SALA',
fileId: 'OBJECT_ID_DEL_FILE',
});
contact.on('new-file-message', (msg) => {
console.log(msg);
// { roomId, sender, file: { id, url, filename, mimetype, type, size }, timestamp }
// Si hay ticket: también incluye messageId y ticketId
});
// ── Obtener historial en memoria de una sala ──────────────────────────────────
// Solo funciona si el socket es participante de la sala
user.emit('get-messages', { roomId: 'UUID-DE-LA-SALA' });
user.on('messages-list', ({ roomId, messages }) => {
console.log('Historial:', messages);
// messages: ChatMessage[] — solo lo que está en memoria (se pierde si el server reinicia)
// Para historial persistente usar GET /api/v1/tickets/:id/messages
});
Mark as read
// Marca como leídos todos los mensajes del contacto en un ticket
// Lo llama el usuario CRM cuando abre la conversación
user.emit('mark-read', { ticketId: 'OBJECT_ID_DEL_TICKET' });
// Todos los clientes conectados reciben el broadcast (no solo la sala):
contact.on('messages-read', ({ ticketId, readBy, readAt }) => {
console.log('Leído por:', readBy, 'a las:', readAt);
});
user.on('messages-read', ({ ticketId, readBy, readAt }) => {
console.log('Confirmado leído:', ticketId);
});
Errores
// Todos los clientes deben escuchar 'exception' para manejar errores del gateway
user.on('exception', ({ message }) => {
switch (message) {
case 'chat.authTimeout': // no se autenticó en 10 s → reconectar
case 'chat.tokenExpired': // token vencido → renovar y reconectar
case 'chat.userNotFound': // id inválido en el sistema externo
case 'chat.roomFull': // sala con 2 participantes ya
case 'chat.roomNotFound': // roomId no existe o es de otro tenant
case 'chat.notParticipant': // no está en Room.participants
case 'chat.sessionInvalidatedTenant': // tenant invalidado en revalidación
console.error('Socket error:', message);
}
});
Diagramas de Secuencia
1. Conexión y Autenticación — CONTACT
El contacto se conecta al namespace /chat y tiene 10 segundos para autenticarse.
sequenceDiagram
participant C as Cliente (Contact)
participant GW as ChatGateway
participant DB as TenantRepository
participant CR as ContactRepository
C->>GW: connect() → namespace /chat
GW-->>GW: setTimeout(AUTH_TIMEOUT = 10 s)
C->>GW: emit('authenticate', { sendType:'contact', token, externalId, username, lang? })
GW->>GW: validateDto(AuthenticateDto)
GW->>DB: findTenantByTokenValue(token)
DB-->>GW: Tenant | null
alt Token inválido / tenant inactivo / status ≠ 'active'
GW-->>C: emit('exception', { message: 'chat.unauthorized' })
GW->>C: disconnect()
else Token encontrado pero expirado
GW-->>C: emit('exception', { message: 'chat.tokenExpired' })
GW->>C: disconnect()
else Token válido
alt externalId o username ausentes
GW-->>C: emit('exception', { message: 'chat.contactFieldsRequired' })
GW->>C: disconnect()
else Campos presentes
GW->>CR: findOrCreate({ externalId, username, tenantId })
Note over CR: Busca por (externalId + tenantId).<br/>Crea si no existe; reutiliza si existe<br/>(username NO se actualiza).
CR-->>GW: { contact, created: boolean }
alt Contacto bloqueado (isBlocked = true)
GW-->>C: emit('exception', { message: 'chat.contactBlocked' })
GW->>C: disconnect()
else Contacto activo
GW->>GW: clearAuthTimer()
GW->>GW: client.data.user = ConnectedUser
GW->>GW: startRevalidationInterval(60 s)
GW-->>C: emit('authenticated', { sendType, tenantId, contactId, externalId, username, message })
end
end
end
2. Conexión y Autenticación — AGENT / SYSTEM
sequenceDiagram
participant A as Cliente (Agent/System)
participant GW as ChatGateway
participant DB as TenantRepository
A->>GW: connect() → namespace /chat
GW-->>GW: setTimeout(AUTH_TIMEOUT = 10 s)
A->>GW: emit('authenticate', { sendType:'agent'|'system', token, userId? })
GW->>GW: validateDto(AuthenticateDto)
GW->>DB: findTenantByTokenValue(token)
DB-->>GW: Tenant | null
alt Token inválido / tenant inactivo
GW-->>A: emit('exception', { message: 'chat.unauthorized' })
GW->>A: disconnect()
else Token expirado
GW-->>A: emit('exception', { message: 'chat.tokenExpired' })
GW->>A: disconnect()
else sendType = 'agent' y userId ausente
GW-->>A: emit('exception', { message: 'chat.agentUserIdRequired' })
GW->>A: disconnect()
else Token válido
GW->>GW: clearAuthTimer()
GW->>GW: client.data.user = ConnectedUser
GW->>GW: startRevalidationInterval(60 s)
GW-->>A: emit('authenticated', { sendType, tenantId, userId?, message })
end
3. Timeout de Autenticación
sequenceDiagram
participant C as Cliente
participant GW as ChatGateway
C->>GW: connect()
GW-->>GW: setTimeout(10 000 ms)
Note over C,GW: 10 segundos sin enviar 'authenticate'
GW-->>C: emit('exception', { message: 'chat.authTimeout' })
GW->>C: disconnect()
4. Revalidación Periódica de Sesión (cada 60 s)
sequenceDiagram
participant GW as ChatGateway
participant DB as TenantRepository
participant CR as ContactRepository
loop Cada 60 segundos
GW->>DB: findTenantByTokenValue(user.tokenValue)
DB-->>GW: Tenant | null
alt Tenant inactivo o token expirado/revocado
GW-->>C: emit('exception', { message: 'chat.sessionInvalidatedTenant' })
GW->>C: disconnect()
else Tenant válido y sendType = 'contact'
GW->>CR: findById(user.contactMongoId)
CR-->>GW: Contact | null
alt Contacto bloqueado o eliminado
GW-->>C: emit('exception', { message: 'chat.sessionInvalidatedContact' })
GW->>C: disconnect()
else Contacto activo
Note over GW: Sesión continúa activa
end
else Tenant válido y sendType ≠ 'contact'
Note over GW: No se verifica contacto — sesión continúa
end
end
5. Crear Sala
sequenceDiagram
participant C as Cliente (autenticado)
participant GW as ChatGateway
participant CS as ChatService
participant TR as TicketRepository
C->>GW: emit('create-room')
GW->>GW: requireAuth(client)
GW->>CS: createRoom(tenantId, tenantMongoId, socketId)
CS-->>GW: Room { id, participants: [socketId], createdAt }
GW->>C: socket.join(room.id)
alt sendType = 'contact'
GW->>TR: findOrCreateTicket(tenantMongoId, contactMongoId)
TR-->>GW: Ticket
GW->>CS: setRoomTicket(roomId, ticketId)
end
GW-->>C: emit('room-created', { roomId, ticketId?, participants, createdAt })
6. Unirse a Sala
sequenceDiagram
participant B as Cliente B (Agent)
participant GW as ChatGateway
participant CS as ChatService
participant TR as TicketRepository
B->>GW: emit('join-room', { roomId })
GW->>GW: requireAuth(client)
GW->>CS: joinRoom(roomId, socketId, tenantId)
Note over CS: Verifica que la sala exista y pertenezca<br/>al tenant. Añade el socketId si hay lugar.
CS-->>GW: Room | null
alt null y sala no existe en el tenant
GW-->>B: emit('exception', { message: 'chat.roomNotFound' })
else null y sala con MAX_PARTICIPANTS (2) alcanzado
GW-->>B: emit('exception', { message: 'chat.roomFull' })
else Room devuelta
GW->>B: socket.join(roomId)
alt sendType = 'contact' y sin ticketId aún
GW->>TR: findOrCreateTicket(tenantMongoId, contactMongoId)
TR-->>GW: Ticket
GW->>CS: setRoomTicket(roomId, ticketId)
end
GW-->>B: emit('room-joined', { roomId, ticketId?, participants, createdAt })
GW-->>Otros: emit('user-joined', { socketId, roomId })
end
7. Enviar Mensaje de Texto
sequenceDiagram
participant C as Cliente (autenticado)
participant GW as ChatGateway
participant CS as ChatService
participant MR as MessageRepository
participant TR as TicketRepository
C->>GW: emit('send-message', { roomId, content })
GW->>GW: requireAuth(client)
GW->>GW: validateDto(SendMessageDto)
GW->>CS: addMessage(roomId, socketId, senderName, content, tenantId)
Note over CS: Retorna null si: la sala no existe,<br/>el tenant no coincide, o el socketId<br/>no está en participants[].
CS-->>GW: ChatMessage | null
alt null (sala no encontrada o cliente no es participante)
GW-->>C: emit('exception', { message: 'chat.messageRoomError' })
else ChatMessage válido
GW->>CS: findRoom(roomId, tenantId)
CS-->>GW: Room
alt Room tiene ticketId (sala persistente)
GW->>MR: create({ tenantId, ticketId, sendType, sendUserId?, body, messageType:CHAT, ack:SENT, parentId? })
MR-->>GW: Message { _id }
GW->>TR: updateLastMessage(ticketId, message._id)
GW->>CS: setRoomLastMessage(roomId, message._id)
GW-->>Sala: emit('new-message', { ...chatMessage, dbId, ticketId })
else Room efímera (sin ticket)
GW-->>Sala: emit('new-message', { id, roomId, senderId, senderName, content, timestamp })
end
end
8. Enviar Archivo
sequenceDiagram
participant C as Cliente (autenticado)
participant GW as ChatGateway
participant FR as FileRepository
participant CS as ChatService
participant MR as MessageRepository
participant TR as TicketRepository
C->>GW: emit('send-file', { roomId, fileId })
GW->>GW: requireAuth(client)
GW->>GW: validateDto(SendFileDto)
GW->>FR: findByIdAndTenant(fileId, tenantMongoId)
FR-->>GW: File | null
alt Archivo no encontrado
GW-->>C: emit('exception', { message: 'file.notFound' })
else Archivo válido
GW->>CS: findRoom(roomId, tenantId)
CS-->>GW: Room | null
alt Sala no encontrada
GW-->>C: emit('exception', { message: 'chat.fileRoomError' })
else Sala válida
alt Room tiene ticketId
GW->>MR: create({ tenantId, ticketId, sendType, sendUserId?, body:filename, MediaId:fileId, messageType:MEDIA, ack:SENT })
MR-->>GW: Message { _id }
GW->>TR: updateLastMessage(ticketId, message._id)
GW->>CS: setRoomLastMessage(roomId, message._id)
GW-->>Sala: emit('new-file-message', { messageId, roomId, ticketId, sender, file:{id,url,filename,mimetype,type,size}, timestamp })
else Room efímera
GW-->>Sala: emit('new-file-message', { roomId, sender, file:{id,url,filename,mimetype,type,size}, timestamp })
end
end
end
9. Marcar Mensajes como Leídos
sequenceDiagram
participant A as Cliente (autenticado)
participant GW as ChatGateway
participant MR as MessageRepository
A->>GW: emit('mark-read', { ticketId })
GW->>GW: requireAuth(client)
GW->>MR: markTicketMessagesAsRead(ticketId)
MR-->>GW: ok
GW-->>Todos: emit('messages-read', { ticketId, readBy: userMongoId|contactMongoId, readAt: Date })
10. Obtener Salas y Mensajes
sequenceDiagram
participant C as Cliente (autenticado)
participant GW as ChatGateway
participant CS as ChatService
C->>GW: emit('get-rooms')
GW->>GW: requireAuth(client)
GW->>CS: getUserRooms(socketId, tenantId)
CS-->>GW: Room[]
GW-->>C: emit('rooms-list', { rooms })
C->>GW: emit('get-messages', { roomId })
GW->>GW: requireAuth(client)
GW->>CS: isParticipant(roomId, socketId, tenantId)
CS-->>GW: boolean
alt No es participante
GW-->>C: emit('exception', { message: 'chat.notParticipant' })
else Es participante
GW->>CS: getMessages(roomId, tenantId)
CS-->>GW: ChatMessage[]
GW-->>C: emit('messages-list', { roomId, messages })
end
11. Salir de una Sala
sequenceDiagram
participant C as Cliente (autenticado)
participant GW as ChatGateway
C->>GW: emit('leave-room', { roomId })
GW->>GW: requireAuth(client)
GW->>C: socket.leave(roomId)
Note over GW: Solo abandona el room de Socket.IO.<br/>El participante NO se elimina del array<br/>en memoria (Room.participants sigue igual).
GW-->>C: emit('room-left', { roomId })
GW-->>Otros: emit('user-left', { socketId, roomId })
12. Desconexión del Cliente
sequenceDiagram
participant C as Cliente
participant GW as ChatGateway
C->>GW: disconnect (cualquier motivo)
GW->>GW: clearAuthTimer()
GW->>GW: clearRevalidationInterval()
Note over GW: Las salas en memoria permanecen intactas.<br/>El socket ya no recibe mensajes pero su ID<br/>sigue en Room.participants hasta que el servidor reinicie.
13. Manejo de Errores (WsExceptionFilter)
sequenceDiagram
participant C as Cliente
participant GW as ChatGateway
participant EF as WsExceptionFilter
C->>GW: emit(cualquier evento)
GW->>GW: handler lanza excepción no controlada
GW->>EF: catch(WsException | HttpException | Error)
alt disconnect: true (decorado con @CatchWebSockets disconnect)
EF-->>C: emit('exception', { message, statusCode })
EF->>C: disconnect()
else solo error
EF-->>C: emit('exception', { message, statusCode })
end
Comportamiento del Contacto — findOrCreate
Cuando un contact se autentica, el gateway llama a ContactRepository.findOrCreate:
| Caso | Resultado |
|---|---|
externalId no existe para el tenant | Se crea un nuevo documento Contact en MongoDB |
externalId ya existe para el tenant | Se reutiliza el documento existente (username no se actualiza) |
Contacto encontrado con isBlocked = true | Autenticación rechazada con exception + disconnect |
El campo created: boolean retornado por findOrCreate no se expone al cliente, pero puede usarse en logs/métricas.
Estado de la sala (in-memory vs. persistente)
| Aspecto | Comportamiento real |
|---|---|
| Almacenamiento | Map<string, Room> en memoria del proceso NestJS |
| Persistencia | Se pierde al reiniciar el servidor |
| Máximo de participantes | 2 por sala |
leave-room | Abandona el room de Socket.IO, pero no elimina el socketId de Room.participants |
disconnect | Tampoco elimina el socketId de Room.participants |
| Mensajes sin ticket | Solo en memoria, no se persisten en MongoDB |
| Mensajes con ticket | Se persisten en la colección Message vía MessageRepository |
Seguridad
- Token del Tenant con
dateExpiration— revalidado cada 60 segundos - Contactos con
isBlocked = trueno pueden autenticarse ni mantener sesión - Timeout de 10 segundos fuerza autenticación inmediata tras conectar
- Máximo 2 participantes por sala (
MAX_PARTICIPANTS = 2) - Todas las operaciones validan que el cliente esté autenticado (
client.data.user) addMessagevalida internamente que el socketId esté enRoom.participants
Mensajes de error i18n
| Clave | Cuándo se emite |
|---|---|
chat.unauthorized | Token inválido, tenant inactivo o status ≠ 'active' |
chat.tokenExpired | Token encontrado pero con dateExpiration pasada |
chat.contactBlocked | Contacto existe pero isBlocked = true al autenticar |
chat.contactFieldsRequired | id ausente en auth de contacto |
chat.userIdRequired | id ausente en auth de usuario |
chat.userNotFound | La API externa no encontró el usuario para el id enviado |
chat.authTimeout | No se recibió authenticate en los 10 s iniciales |
chat.tokenExpiredReconnect | Token expiró durante sesión activa (verificado en getUser) |
chat.sessionInvalidatedTenant | Tenant ya no válido durante revalidación periódica |
chat.sessionInvalidatedContact | Contacto bloqueado durante revalidación periódica |
chat.notAuthenticated | Evento enviado antes de completar authenticate |
chat.roomNotFound | roomId no existe o no pertenece al tenant |
chat.roomFull | Sala con 2 participantes ya (MAX_PARTICIPANTS) |
chat.notParticipant | Socket no está en Room.participants para get-messages |
chat.messageRoomError | addMessage retornó null (sala inexistente o no participante) |
chat.fileRoomError | Sala no encontrada al enviar archivo |
file.notFound | Archivo no encontrado en la colección File |