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-room—Crear 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-rooms—Listar 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 (en el código actual CHAT_EVENTS lo reemplaza message_ack_changed, que lleva ids explícitos)
message_ack_changedCambio de ack de mensajes. Payload { ticketId, messageIds, ack, at, failureReason? }; se emite a las salas ticket:<id> y tenant:<id>. failureReason solo con ack: failed (ver Message). Lo disparan los acuses de WhatsApp/Meta y los fallos de envío saliente
new_note, ticket_updated, ticket_createdEventos emitidos por SocketBroadcastService desde servicios HTTP / jobs / canales externos (los mensajes entrantes de Meta y WhatsApp llegan al front por aquí)
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.


2. Conexión y Autenticación — AGENT / SYSTEM​


3. Timeout de Autenticación​


4. Revalidación Periódica de Sesión (cada 60 s)​


5. Crear Sala​


6. Unirse a Sala​


7. Enviar Mensaje de Texto​


8. Enviar Archivo​


9. Marcar Mensajes como Leídos​


10. Obtener Salas y Mensajes​


11. Salir de una Sala​


12. Desconexión del Cliente​


13. Manejo de Errores (WsExceptionFilter)​


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