Módulo Scheduled Messages
src/app/modules/scheduled-message/
Permite que un agente programe un mensaje de chat o una nota interna para enviarse más tarde dentro de un ticket. El envío usa una cola BullMQ con retraso, un barrido de reconciliación y un reclamo atómico para evitar envíos duplicados. Cada tenant lo habilita y limita desde Settings (scheduledMessages).
Modelo — ScheduledMessage
Colección scheduled_messages, con timestamps.
| Campo | Tipo | Descripción |
|---|---|---|
tenantId | ObjectId → Tenant | Tenant |
ticketId | ObjectId → Ticket | Ticket destino |
contactIdSnapshot | ObjectId → Contact | Contacto del ticket al crear el registro |
authorUserId | ObjectId → User | Agente autor |
authorNameSnapshot | string | Nombre del autor al programar (nombre y apellido, o username) |
authorSendType | enum | user o agent (los contactos nunca programan) |
messageType | enum | chat o note |
body | string | Texto |
signature | boolean | Si se firma el mensaje al enviarlo (por defecto true); queda congelado al programar |
parentId | ObjectId → Message | Opcional; mensaje al que responde |
mediaId | ObjectId → File | Reservado; los adjuntos no están soportados |
scheduledAt | Date | Momento de envío, siempre en UTC |
timezoneSnapshot | string | Zona horaria del tenant al crear |
status | enum | pending (por defecto), processing, sent, cancelled, failed |
cancelReason | enum | null | agent, owner_inactive, ticket_resolved, ticket_deleted, feature_disabled |
claimedAt | Date | Marca del reclamo atómico |
sentMessageId / sentAt | ObjectId / Date | Mensaje real creado y momento de envío |
attemptCount | number | Intentos de envío (por defecto 0) |
lastError | string | null | Último error |
queueJobId | string | null | Para auditoría; equivale al _id |
Índices: parcial sobre scheduledAt (solo pending), parcial sobre claimedAt (solo processing), (tenantId, ticketId, status) y (tenantId, authorUserId, status, scheduledAt).
Endpoints
Base: /api/v1/scheduled-messages. Controlador con @Auth() y PermissionsGuard. No existen permisos propios: se reutiliza el módulo MESSAGE del catálogo. Además, toda consulta o mutación se limita a { tenantId, authorUserId }: cada agente solo ve y modifica los suyos.
| Método | Ruta | Permiso | Descripción |
|---|---|---|---|
POST | /scheduled-messages/create | MESSAGE_CREATE | Programa un mensaje |
GET | /scheduled-messages/mine | MESSAGE_READ | Mensajes programados del agente, paginados |
GET | /scheduled-messages/by-ticket/:ticketId | MESSAGE_READ | Programados del agente para un ticket (data.results) |
PATCH | /scheduled-messages/update/:id | MESSAGE_UPDATE | Edita texto o fecha de un mensaje pendiente |
PATCH | /scheduled-messages/cancel/:id | MESSAGE_DELETE | Cancela un mensaje pendiente (no hay borrado físico) |
mine devuelve data.results y data.meta { total, page, limit }.
DTOs
Los DTO solo validan forma; las reglas que dependen del tenant (anticipación mínima, horizonte, límites) se evalúan en el servicio con Settings.
Parámetros de ruta
| Parámetro | Tipo | Obligatorio | Validación | Usado en |
|---|---|---|---|---|
ticketId | string | Sí | Ninguna en el controlador; se convierte con new Types.ObjectId | by-ticket/:ticketId |
id | string | Sí | Ninguna; se convierte con new Types.ObjectId | update/:id, cancel/:id |
CreateScheduledMessageDto (body de POST /scheduled-messages/create)
| Campo | Tipo | Obligatorio | Validaciones | Descripción |
|---|---|---|---|---|
ticketId | string | Sí | @IsMongoId | Ticket destino |
body | string | Sí | @IsString, @IsNotEmpty | Texto |
scheduledAt | string | Sí | @IsDateString | Fecha ISO 8601 de envío |
messageType | string | Sí | @IsIn: chat, note | Tipo de mensaje |
parentId | string | No | @IsMongoId | Mensaje al que responde |
signature | boolean | No | @IsBoolean | Firmar el mensaje; si se omite se firma |
UpdateScheduledMessageDto (body de PATCH /scheduled-messages/update/:id)
| Campo | Tipo | Obligatorio | Validaciones | Descripción |
|---|---|---|---|---|
body | string | No | @IsString, @IsNotEmpty | Nuevo texto |
scheduledAt | string | No | @IsDateString | Nueva fecha; se revalidan anticipación y horizonte y se reprograma el job |
QueryScheduledMessageDto (query de GET /scheduled-messages/mine)
| Campo | Tipo | Obligatorio | Validaciones | Descripción |
|---|---|---|---|---|
page | number | No | @Transform(toInt), @IsInt, @Min(1) | Página (por defecto 1) |
limit | number | No | @Transform(toInt), @IsInt, @Min(1), @Max(100) | Tamaño de página (por defecto 10) |
status | string | No | @IsIn: pending, processing, sent, cancelled, failed | Filtra por estado |
Reglas de negocio al crear
ScheduledMessageService.create rechaza la solicitud si:
scheduledMessages.enabledesfalseen Settings (403).messageTypeesnoteyallowNotesesfalse(400).- La fecha es inválida o ya pasó (
400), o está antes deminLeadTimeMinuteso después demaxHorizonDays(400). - El ticket no existe en el tenant (
404). - Los pendientes del ticket alcanzan
maxPendingPerTicket, o los del agente alcanzanmaxPendingPerAgent(400).
Después guarda el registro, encola el job y notifica por socket.
Cola, procesador y barrido
- Cola:
scheduled-message(BullMQ). El jobsendlleva solo{ id }, usa el_idcomojobId, retraso hastascheduledAt, 3 intentos con backoff exponencial base de 30 s,removeOnCompletey conserva los fallidos. Elenqueuefalla si Redis no responde en 6 s. ScheduledMessageProcessor: worker que llama aScheduledMessageSenderService.send(id).ScheduledMessageSenderService.send: reclama el registro de forma atómica (claimForSend); si otro worker ya lo reclamó, no hace nada. Al enviar, vuelve a evaluar el estado actual:- feature desactivada: cancela con
feature_disabled; - ticket inexistente:
ticket_deleted; - ticket resuelto y
onTicketResolved = cancel:ticket_resolved; - autor inactivo y
onOwnerInactive = cancel:owner_inactivey emite un mensaje de sistema al ticket (usacancelledSystemMessageo un texto por defecto). - En otro caso crea el
Message(nota o chat firmado con el nombre del autor, salvosignature = false), actualiza el último mensaje del ticket, emite por socket y enruta el envío saliente porOutboundRouterService.
- feature desactivada: cancela con
- Fallos: mientras
attemptCountsea menor que 3 vuelve apendingy relanza el error para que BullMQ reintente; al tercer intento quedafailedy emite el evento correspondiente. ScheduledMessageReconciliationJob(@Interval, cada 60 s) ejecutareconcile: envía los pendientes vencidos, vuelve a encolar los pendientes sin job en la cola y recupera reclamosprocessingcon más de 5 minutos reiniciándolos apending.ScheduledMessageCancellationService.cancelForTicket: cancela en bloque los pendientes de un ticket (lo invocan los flujos de resolución de tickets), quita los jobs y emite el evento de cancelación.
Eventos de socket
Se emiten solo al autor, en la sala user:{authorId} (nunca a la sala del ticket):
| Evento | Payload |
|---|---|
scheduled_message_created | { item } con el registro completo (clave _id) |
scheduled_message_updated | { item } con el registro completo |
scheduled_message_sent | { id, ticketId, messageId } |
scheduled_message_cancelled | { id, ticketId, reason } |
scheduled_message_failed | { id, ticketId, error } |
Gotchas
- Editar solo el texto no toca la cola: el job lleva solo el
idy el cuerpo se lee de MongoDB al enviar. updateycancelsobre un mensaje que no estápending(o que no es del agente) responden404con el mensajenotPending.- Los límites y políticas son del tenant (ver Settings); las constantes de infraestructura (intentos, backoff, reclamo obsoleto, intervalo) son globales en
queue.constants.ts. mediaIdestá reservado: no hay adjuntos en programados.