Skip to main content

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 un Ticket en MongoDB
  • Sin estado persistente de sala — si el servidor reinicia, las salas en memoria se pierden

Tipos de usuario

typeDescripciónDatos requeridos al autenticar
contactCliente externo que inicia la conversaciónid (externalId numérico) — se busca en la API externa y se upserta en MongoDB
userAgente/operador del CRMid (externalId numérico) — se busca en la API externa y se resuelve contra User.externalId
systemProceso automatizadoninguno adicional

Archivos

ArchivoDescripción
chat.module.tsImporta TenantModule, ContactModule, TicketModule, MessageModule, FileModule
gateways/chat.gateway.tsGateway principal con todos los event handlers
gateways/chat.gateway.spec.ts48 tests del gateway (unitarios)
services/chat.service.tsGestión de salas y mensajes en memoria
services/chat.service.spec.ts26 tests del servicio
filters/ws-exception.filter.tsFiltro de errores para WebSocket
interfaces/chat.interfaces.tsAuthSocket, 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

EventoDTODescripción
authenticateAuthenticateDtoAutenticación con token de tenant
create-roomCrear sala nueva en memoria
join-roomJoinRoomDtoUnirse a sala existente
send-messageSendMessageDtoEnviar mensaje de texto
send-fileSendFileDtoEnviar archivo (requiere fileId previo)
mark-readMarkReadDtoMarcar mensajes del ticket como leídos
get-roomsListar salas activas del usuario
get-messagesJoinRoomDtoHistorial en memoria de una sala
leave-roomJoinRoomDtoSalir del room de Socket.IO

Servidor → Cliente

EventoDescripción
authenticatedRespuesta exitosa a authenticate
room-createdSala creada (al creador)
room-joinedConfirmación de unión a sala
user-joinedBroadcast a otros participantes cuando alguien se une
room-leftConfirmación de salida
user-leftBroadcast a participantes restantes cuando alguien sale
new-messageNuevo mensaje de texto en la sala
new-file-messageNuevo mensaje con archivo en la sala
messages-readBroadcast cuando mensajes son marcados como leídos
rooms-listLista de salas del usuario
messages-listLista de mensajes en memoria de una sala
exceptionError 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 el User en MongoDB por externalId. Si existe, su _id queda en sesión; si no existe, la sesión funciona igual (sin userId en DB).

type: 'system' — No requiere id. 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:

CasoResultado
externalId no existe para el tenantSe crea un nuevo documento Contact en MongoDB
externalId ya existe para el tenantSe reutiliza el documento existente (username no se actualiza)
Contacto encontrado con isBlocked = trueAutenticació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)

AspectoComportamiento real
AlmacenamientoMap<string, Room> en memoria del proceso NestJS
PersistenciaSe pierde al reiniciar el servidor
Máximo de participantes2 por sala
leave-roomAbandona el room de Socket.IO, pero no elimina el socketId de Room.participants
disconnectTampoco elimina el socketId de Room.participants
Mensajes sin ticketSolo en memoria, no se persisten en MongoDB
Mensajes con ticketSe persisten en la colección Message vía MessageRepository

Seguridad

  • Token del Tenant con dateExpiration — revalidado cada 60 segundos
  • Contactos con isBlocked = true no 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)
  • addMessage valida internamente que el socketId esté en Room.participants

Mensajes de error i18n

ClaveCuándo se emite
chat.unauthorizedToken inválido, tenant inactivo o status ≠ 'active'
chat.tokenExpiredToken encontrado pero con dateExpiration pasada
chat.contactBlockedContacto existe pero isBlocked = true al autenticar
chat.contactFieldsRequiredid ausente en auth de contacto
chat.userIdRequiredid ausente en auth de usuario
chat.userNotFoundLa API externa no encontró el usuario para el id enviado
chat.authTimeoutNo se recibió authenticate en los 10 s iniciales
chat.tokenExpiredReconnectToken expiró durante sesión activa (verificado en getUser)
chat.sessionInvalidatedTenantTenant ya no válido durante revalidación periódica
chat.sessionInvalidatedContactContacto bloqueado durante revalidación periódica
chat.notAuthenticatedEvento enviado antes de completar authenticate
chat.roomNotFoundroomId no existe o no pertenece al tenant
chat.roomFullSala con 2 participantes ya (MAX_PARTICIPANTS)
chat.notParticipantSocket no está en Room.participants para get-messages
chat.messageRoomErroraddMessage retornó null (sala inexistente o no participante)
chat.fileRoomErrorSala no encontrada al enviar archivo
file.notFoundArchivo no encontrado en la colección File