Módulo Message
src/app/modules/message/
Persistencia de mensajes de las conversaciones.
Modelo — Message
| Campo | Tipo | Descripción |
|---|---|---|
_id | ObjectId | ID de MongoDB |
tenantId | ObjectId → Tenant | — |
ticketId | ObjectId → Ticket | Conversación a la que pertenece |
sendType | string | contact | agent | user | system |
messageType | string | chat | media | system |
sendUserRefModel | string | User | Contact — modelo de referencia polimórfico |
sendUserId | ObjectId (refPath) | Emisor: Contact._id si sendType=contact, User._id si agent/user |
body | string | Contenido del mensaje |
parentId | ObjectId → Message | Mensaje al que responde (threading) |
MediaId | ObjectId → File | Archivo adjunto (si messageType=media) |
ack | string | sent | delivered | read | failed (nuevo: rechazo definitivo del proveedor) |
failureReason | string | Solo con ack: failed: out_of_window, auth_error, rate_limited, unsupported_media, recipient_unavailable, unknown (MESSAGE_FAILURE_REASON) |
failureDetail | string | Respuesta cruda del proveedor, para soporte/logs; no se muestra tal cual al agente |
externalMessageId | string | Id del mensaje en el canal externo (Meta mid, id de WhatsApp). Indexado, sparse. Sirve para idempotencia y para correlacionar acuses |
connectionId | ObjectId → Connection | Conexión por la que circula el mensaje |
sharedContacts | { name, phone }[] | Contactos compartidos recibidos por WhatsApp |
isRead | boolean | Si el agente lo marcó como leído |
readAt | Date | Fecha de lectura |
createdAt | Date | — |
updatedAt | Date | — |
Endpoints
CRUD general
Controlador con @Auth() y PermissionsGuard. Las rutas siguen el patrón CRUD_ROUTES.
| Método | Ruta | Permiso | Descripción |
|---|---|---|---|
POST | /api/v1/messages/create | MESSAGE_CREATE | Crea mensaje |
GET | /api/v1/messages/get-all | MESSAGE_READ | Lista mensajes (filtrable por ticketId) |
GET | /api/v1/messages/get-one/:id | MESSAGE_READ | Obtiene un mensaje |
PATCH | /api/v1/messages/update/:id | MESSAGE_UPDATE | Actualiza (ej: cambiar ack) |
DELETE | /api/v1/messages/remove/:id | MESSAGE_DELETE | Elimina mensaje |
PATCH | /api/v1/messages/mark-read/:id | MESSAGE_UPDATE | Marca un mensaje como leído |
PATCH | /api/v1/messages/mark-read-ticket/:ticketId | MESSAGE_UPDATE | Marca todos los mensajes de un ticket como leídos |
GET | /api/v1/messages/unread-count | MESSAGE_READ | Conteo de mensajes no leídos |
GET | /api/v1/messages/recent-unread | MESSAGE_READ | Mensajes no leídos recientes |
GET | /api/v1/messages/:id/window | MESSAGE_READ | Ventana de mensajes alrededor de uno (radius, ver WindowQueryDto) |
Por contacto
Endpoints de consulta de historial centrados en el contacto. Todos filtran automáticamente por tenantId del usuario autenticado.
Orden de registro en el router:
/:contactId/tickets-allse registra antes de/:contactId/:ticketIdpara evitar que Express capture el literaltickets-allcomo un parámetro:ticketId.
| Método | Ruta | Permiso | Descripción |
|---|---|---|---|
GET | /api/v1/messages/:contactId/tickets-all | @Auth() | Todos los tickets del contacto con su último mensaje |
GET | /api/v1/messages/:contactId | @Auth() | Todos los mensajes del contacto (todos sus tickets) |
GET | /api/v1/messages/:contactId/:ticketId | @Auth() | Mensajes de un ticket específico del contacto |
Endpoints por contacto — detalle
GET /api/v1/messages/:contactId/tickets-all
Devuelve todos los tickets del contacto con el último mensaje de cada uno populado, junto con el remitente, el agente asignado y el conteo de mensajes no leídos. Ordenados por fecha del último mensaje (más reciente primero).
Delega internamente al mismo pipeline de TicketService.getTicketsList con contactId como filtro, por lo que la respuesta es idéntica a la de GET /api/v1/tickets/get-all.
Query params
| Parámetro | Tipo | Por defecto | Descripción |
|---|---|---|---|
page | number | 1 | Número de página (base 1) |
limit | number | 10 | Ítems por página (máx. 100) |
createdFrom | string (ISO 8601) | — | Tickets creados desde esta fecha |
createdTo | string (ISO 8601) | — | Tickets creados hasta esta fecha |
status | string | — | Estado del ticket: open | closed | inProgress | pending | resolved | cancelled |
Respuesta exitosa — 200 OK
{
"message": "message.findAll",
"data": {
"results": [
{
"_id": "507f1f77bcf86cd799439011",
"identifier": "uuid-1234",
"status": "open",
"lastMessage": {
"_id": "507f1f77bcf86cd799439020",
"body": "Hola, necesito ayuda",
"messageType": "chat",
"sendType": "contact",
"ack": "read",
"isRead": true,
"sentAt": "2026-05-20T10:00:00Z",
"sender": {
"_id": "507f1f77bcf86cd799439013",
"username": "juan_perez"
}
},
"contact": {
"_id": "507f1f77bcf86cd799439012",
"username": "juan_perez",
"phone": "+5491112345678",
"isActive": true,
"isBlocked": false,
"labels": []
},
"unreadCount": 3,
"assignedUser": null,
"createdAt": "2026-05-19T09:00:00Z",
"updatedAt": "2026-05-20T10:00:00Z"
}
],
"meta": {
"total": 5,
"page": 1,
"limit": 10,
"totalPages": 1
}
}
}
GET /api/v1/messages/:contactId
Devuelve todos los mensajes del contacto a través de todos sus tickets, ordenados del más reciente al más antiguo. Cada mensaje incluye el sender (Contact o User según sendType) y el parent (mensaje al que responde, si existe).
Query params
| Parámetro | Tipo | Por defecto | Descripción |
|---|---|---|---|
page | number | 1 | Número de página (base 1) |
limit | number | 10 | Mensajes por página (máx. 100) |
createdFrom | string (ISO 8601) | — | Mensajes enviados desde esta fecha |
createdTo | string (ISO 8601) | — | Mensajes enviados hasta esta fecha |
messageType | string | — | chat | media | system |
sendType | string | — | contact | agent | user | system |
Respuesta exitosa — 200 OK
{
"message": "message.findAll",
"data": {
"results": [
{
"_id": "507f1f77bcf86cd799439021",
"ticketId": "507f1f77bcf86cd799439011",
"body": "¡Claro! Te ayudo ahora.",
"messageType": "chat",
"sendType": "agent",
"ack": "delivered",
"isRead": false,
"readAt": null,
"MediaId": null,
"parentId": "507f1f77bcf86cd799439020",
"createdAt": "2026-05-20T10:05:00Z",
"updatedAt": "2026-05-20T10:05:00Z",
"parent": {
"_id": "507f1f77bcf86cd799439020",
"body": "Hola, necesito ayuda",
"messageType": "chat",
"sendType": "contact",
"createdAt": "2026-05-20T10:00:00Z"
},
"sender": {
"_id": "507f1f77bcf86cd799439030",
"username": "agent_laura",
"firstname": "Laura",
"lastName": "García",
"email": "laura@empresa.com"
}
}
],
"meta": {
"total": 42,
"page": 1,
"limit": 10,
"totalPages": 5
}
}
}
Notas
parentesnullcuando el mensaje no es una respuesta directa.senderexpone campos deContact(username,firstname,lastName,profilePic) o deUser(username,firstname,lastName,email) según elsendType.- El pipeline hace un
$lookupal collectionticketspara filtrar porContactId, por lo que solo se devuelven mensajes de tickets que pertenecen al contacto indicado dentro del tenant.
GET /api/v1/messages/:contactId/:ticketId
Devuelve los mensajes de un ticket específico perteneciente al contacto, ordenados del más reciente al más antiguo. Verifica que el ticket pertenezca al contacto antes de consultar; si no, devuelve 404.
Path params
| Parámetro | Descripción |
|---|---|
contactId | MongoDB ObjectId del contacto |
ticketId | MongoDB ObjectId del ticket |
Query params
| Parámetro | Tipo | Por defecto | Descripción |
|---|---|---|---|
page | number | 1 | Número de página (base 1) |
limit | number | 10 | Mensajes por página (máx. 100) |
createdFrom | string (ISO 8601) | — | Mensajes enviados desde esta fecha |
createdTo | string (ISO 8601) | — | Mensajes enviados hasta esta fecha |
messageType | string | — | chat | media | system |
sendType | string | — | contact | agent | user | system |
Respuesta exitosa — 200 OK
La forma de cada mensaje es idéntica a la de GET /:contactId, incluyendo parent y sender.
Error — 404 Not Found
Devuelto cuando el ticketId no existe o no pertenece al contactId dentro del tenant del usuario autenticado.
{
"statusCode": 404,
"message": "ticket.notFound"
}
DTOs de filtro
MessageContactFilterDto
Usado por GET /:contactId y GET /:contactId/:ticketId.
class MessageContactFilterDto {
page?: number // default 1, min 1
limit?: number // default 10, max 100
createdFrom?: string // ISO 8601
createdTo?: string // ISO 8601
messageType?: 'chat' | 'media' | 'system'
sendType?: 'contact' | 'agent' | 'user' | 'system'
}
TicketContactFilterDto
Usado por GET /:contactId/tickets-all.
class TicketContactFilterDto {
page?: number // default 1, min 1
limit?: number // default 10, max 100
createdFrom?: string // ISO 8601 — fecha de creación del ticket
createdTo?: string // ISO 8601 — fecha de creación del ticket
status?: 'open' | 'closed' | 'inProgress' | 'pending' | 'resolved' | 'cancelled'
}
Tipos de mensaje
messageType | sendType | Descripción |
|---|---|---|
chat | contact | Mensaje de texto del cliente externo |
chat | agent | Respuesta del agente |
media | cualquiera | Mensaje con archivo adjunto (MediaId) |
system | system | Evento automático (ticket creado, cerrado, etc.) |
Flujo con ChatGateway
El ChatGateway crea los mensajes en BD llamando MessageService tras recibir un evento WebSocket SEND_MESSAGE o SEND_FILE. Los endpoints HTTP son para consulta e historial.
Dependencias del módulo
MessageModule
├── MongooseModule (Message schema)
├── UserModule
└── TicketModule ← importado para los endpoints por contacto
├── TicketService ← getTicketsList, findByContactAndTicket
└── TicketRepository
Estados de ack y fallos
src/app/core/constants/message.constants.ts
MESSAGE_ACK:sent,delivered,read,failed.- Las transiciones son condicionales (
MESSAGE_ACK_PRECEDING):deliveredsolo desdesent;readdesdesentodelivered;failedsolo desdesent. Un acuse tardío nunca degrada un mensaje ya leído. failedno se puede crear ni fijar por la API:CreateMessageDto.ackvalida contraMESSAGE_ACK_CREATABLE_VALUES(sinfailed). Solo lo escriben los dispatchers salientes medianteMessageRepository.markFailed(messageId, reason, detail)(por_id, porque un envío fallido no devuelve id externo) omarkFailedByExternalMessageId(hoy sin uso).- Los avances por
externalMessageId(advanceAckByExternalMessageId), por ticket (advanceTicketAck,advanceAckUpTo,advanceMessagesAck) ignoranfailed. - Cada cambio se emite por socket como
message_ack_changedcon{ ticketId, messageIds, ack, at, failureReason? }a las salasticket:<id>ytenant:<id>(SocketBroadcastService.broadcastMessageAckChanged).
Salida (outbound)
Un único punto de salida del CRM hacia canales externos: OutboundRouterService (services/outbound-router.service.ts).
route(ticket, message)resuelve la conexión del ticket una sola vez y pide alOutboundDispatcherRegistryel dispatcher que la atiende.- Si no hay dispatcher (canal
web: la entrega es el propio emit por socket) no hace nada. - Nunca lanza: el mensaje ya está guardado y mostrado al agente; un canal caído deja el
ackensent. routeRead(ticket, messageIds)envía el acuse de lectura al canal (si el dispatcher implementamarkRead).
OutboundDispatcher (interfaces/outbound-dispatcher.interface.ts) declara su channelType, dispatch, opcionalmente markRead y supports(connection). OutboundDispatcherRegistry permite varias implementaciones por canal (autorregistro en onModuleInit, no un token DI, para que un módulo no pise a otro); como máximo un dispatcher sin supports() por canal.
Dispatchers registrados en esta rama: WhatsappOutboundDispatcher (whatsapp-qr) y MetaOutboundDispatcher (instagram, facebook, ver Meta). ChatGateway, SystemMessageService y ScheduledMessageSenderService envían a través del router.
Pipeline de entrada compartido
services/inbound-message.pipeline.ts expone ingestInbound(deps, context, port), usado por WhatsApp y Meta (antes la lógica estaba en el gateway). Persiste el mensaje, actualiza el último mensaje del ticket, gestiona la asignación al recibir mensaje de contacto, el flujo de CSAT y los saludos, y emite los eventos por socket.
IngestInboundDeps: repositorios,TicketService,TenantSettingsService,SystemMessageService,resolveParent,getSenderName.IngestInboundContext:ticket,created,user,content,parentId?,externalMessageId?,media?,viewOnce?,isLocation?,sharedContacts?.inbound-channel.adapter.ts(buildInboundDeps,buildInboundPort) arma las dependencias y el puerto de emisión sobreSocketBroadcastServicepara los canales que no tienen socket de gateway.helpers/parent-message.helper.ts(resolveParentMessage) valida que el mensaje citado pertenezca al mismo contacto antes de aceptarlo comoparentId.
Inbound lock
inbound-lock/ — InboundLockService.withContactLock(tenantId, externalId, fn).
Lock distribuido en Redis (SET key token PX 10000 NX, clave inbound-lock:<tenantId>:<externalId>, reintento cada 50 ms hasta 10 s, liberación con script Lua que compara el token). Evita que dos mensajes simultáneos de un mismo contacto nuevo creen dos tickets. Degrada sin lock (con warning) si Redis no responde (timeout de comandos 500 ms) o no se obtiene a tiempo: el procesamiento continúa. Constantes en INBOUND_LOCK_DEFAULTS (core/constants/queue.constants.ts).