Skip to main content

Módulo Message

src/app/modules/message/

Persistencia de mensajes de las conversaciones.

Modelo — Message​

CampoTipoDescripción
_idObjectIdID de MongoDB
tenantIdObjectId → Tenant—
ticketIdObjectId → TicketConversación a la que pertenece
sendTypestringcontact | agent | user | system
messageTypestringchat | media | system
sendUserRefModelstringUser | Contact — modelo de referencia polimórfico
sendUserIdObjectId (refPath)Emisor: Contact._id si sendType=contact, User._id si agent/user
bodystringContenido del mensaje
parentIdObjectId → MessageMensaje al que responde (threading)
MediaIdObjectId → FileArchivo adjunto (si messageType=media)
ackstringsent | delivered | read | failed (nuevo: rechazo definitivo del proveedor)
failureReasonstringSolo con ack: failed: out_of_window, auth_error, rate_limited, unsupported_media, recipient_unavailable, unknown (MESSAGE_FAILURE_REASON)
failureDetailstringRespuesta cruda del proveedor, para soporte/logs; no se muestra tal cual al agente
externalMessageIdstringId del mensaje en el canal externo (Meta mid, id de WhatsApp). Indexado, sparse. Sirve para idempotencia y para correlacionar acuses
connectionIdObjectId → ConnectionConexión por la que circula el mensaje
sharedContacts{ name, phone }[]Contactos compartidos recibidos por WhatsApp
isReadbooleanSi el agente lo marcó como leído
readAtDateFecha de lectura
createdAtDate—
updatedAtDate—

Endpoints​

CRUD general​

Controlador con @Auth() y PermissionsGuard. Las rutas siguen el patrón CRUD_ROUTES.

MétodoRutaPermisoDescripción
POST/api/v1/messages/createMESSAGE_CREATECrea mensaje
GET/api/v1/messages/get-allMESSAGE_READLista mensajes (filtrable por ticketId)
GET/api/v1/messages/get-one/:idMESSAGE_READObtiene un mensaje
PATCH/api/v1/messages/update/:idMESSAGE_UPDATEActualiza (ej: cambiar ack)
DELETE/api/v1/messages/remove/:idMESSAGE_DELETEElimina mensaje
PATCH/api/v1/messages/mark-read/:idMESSAGE_UPDATEMarca un mensaje como leído
PATCH/api/v1/messages/mark-read-ticket/:ticketIdMESSAGE_UPDATEMarca todos los mensajes de un ticket como leídos
GET/api/v1/messages/unread-countMESSAGE_READConteo de mensajes no leídos
GET/api/v1/messages/recent-unreadMESSAGE_READMensajes no leídos recientes
GET/api/v1/messages/:id/windowMESSAGE_READVentana 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-all se registra antes de /:contactId/:ticketId para evitar que Express capture el literal tickets-all como un parámetro :ticketId.

MétodoRutaPermisoDescripció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ámetroTipoPor defectoDescripción
pagenumber1Número de página (base 1)
limitnumber10Ítems por página (máx. 100)
createdFromstring (ISO 8601)—Tickets creados desde esta fecha
createdTostring (ISO 8601)—Tickets creados hasta esta fecha
statusstring—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ámetroTipoPor defectoDescripción
pagenumber1Número de página (base 1)
limitnumber10Mensajes por página (máx. 100)
createdFromstring (ISO 8601)—Mensajes enviados desde esta fecha
createdTostring (ISO 8601)—Mensajes enviados hasta esta fecha
messageTypestring—chat | media | system
sendTypestring—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

  • parent es null cuando el mensaje no es una respuesta directa.
  • sender expone campos de Contact (username, firstname, lastName, profilePic) o de User (username, firstname, lastName, email) según el sendType.
  • El pipeline hace un $lookup al collection tickets para filtrar por ContactId, 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ámetroDescripción
contactIdMongoDB ObjectId del contacto
ticketIdMongoDB ObjectId del ticket

Query params

ParámetroTipoPor defectoDescripción
pagenumber1Número de página (base 1)
limitnumber10Mensajes por página (máx. 100)
createdFromstring (ISO 8601)—Mensajes enviados desde esta fecha
createdTostring (ISO 8601)—Mensajes enviados hasta esta fecha
messageTypestring—chat | media | system
sendTypestring—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​

messageTypesendTypeDescripción
chatcontactMensaje de texto del cliente externo
chatagentRespuesta del agente
mediacualquieraMensaje con archivo adjunto (MediaId)
systemsystemEvento 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): delivered solo desde sent; read desde sent o delivered; failed solo desde sent. Un acuse tardío nunca degrada un mensaje ya leído.
  • failed no se puede crear ni fijar por la API: CreateMessageDto.ack valida contra MESSAGE_ACK_CREATABLE_VALUES (sin failed). Solo lo escriben los dispatchers salientes mediante MessageRepository.markFailed(messageId, reason, detail) (por _id, porque un envío fallido no devuelve id externo) o markFailedByExternalMessageId (hoy sin uso).
  • Los avances por externalMessageId (advanceAckByExternalMessageId), por ticket (advanceTicketAck, advanceAckUpTo, advanceMessagesAck) ignoran failed.
  • Cada cambio se emite por socket como message_ack_changed con { ticketId, messageIds, ack, at, failureReason? } a las salas ticket:<id> y tenant:<id> (SocketBroadcastService.broadcastMessageAckChanged).

Salida (outbound)​

Un único punto de salida del CRM hacia canales externos: OutboundRouterService (services/outbound-router.service.ts).

  1. route(ticket, message) resuelve la conexión del ticket una sola vez y pide al OutboundDispatcherRegistry el dispatcher que la atiende.
  2. Si no hay dispatcher (canal web: la entrega es el propio emit por socket) no hace nada.
  3. Nunca lanza: el mensaje ya está guardado y mostrado al agente; un canal caído deja el ack en sent.
  4. routeRead(ticket, messageIds) envía el acuse de lectura al canal (si el dispatcher implementa markRead).

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 sobre SocketBroadcastService para 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 como parentId.

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).