Skip to main content

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.

CampoTipoDescripción
tenantIdObjectId → TenantTenant
ticketIdObjectId → TicketTicket destino
contactIdSnapshotObjectId → ContactContacto del ticket al crear el registro
authorUserIdObjectId → UserAgente autor
authorNameSnapshotstringNombre del autor al programar (nombre y apellido, o username)
authorSendTypeenumuser o agent (los contactos nunca programan)
messageTypeenumchat o note
bodystringTexto
signaturebooleanSi se firma el mensaje al enviarlo (por defecto true); queda congelado al programar
parentIdObjectId → MessageOpcional; mensaje al que responde
mediaIdObjectId → FileReservado; los adjuntos no están soportados
scheduledAtDateMomento de envío, siempre en UTC
timezoneSnapshotstringZona horaria del tenant al crear
statusenumpending (por defecto), processing, sent, cancelled, failed
cancelReasonenum | nullagent, owner_inactive, ticket_resolved, ticket_deleted, feature_disabled
claimedAtDateMarca del reclamo atómico
sentMessageId / sentAtObjectId / DateMensaje real creado y momento de envío
attemptCountnumberIntentos de envío (por defecto 0)
lastErrorstring | nullÚltimo error
queueJobIdstring | nullPara 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étodoRutaPermisoDescripción
POST/scheduled-messages/createMESSAGE_CREATEPrograma un mensaje
GET/scheduled-messages/mineMESSAGE_READMensajes programados del agente, paginados
GET/scheduled-messages/by-ticket/:ticketIdMESSAGE_READProgramados del agente para un ticket (data.results)
PATCH/scheduled-messages/update/:idMESSAGE_UPDATEEdita texto o fecha de un mensaje pendiente
PATCH/scheduled-messages/cancel/:idMESSAGE_DELETECancela 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ámetroTipoObligatorioValidaciónUsado en
ticketIdstringSíNinguna en el controlador; se convierte con new Types.ObjectIdby-ticket/:ticketId
idstringSíNinguna; se convierte con new Types.ObjectIdupdate/:id, cancel/:id

CreateScheduledMessageDto (body de POST /scheduled-messages/create)​

CampoTipoObligatorioValidacionesDescripción
ticketIdstringSí@IsMongoIdTicket destino
bodystringSí@IsString, @IsNotEmptyTexto
scheduledAtstringSí@IsDateStringFecha ISO 8601 de envío
messageTypestringSí@IsIn: chat, noteTipo de mensaje
parentIdstringNo@IsMongoIdMensaje al que responde
signaturebooleanNo@IsBooleanFirmar el mensaje; si se omite se firma

UpdateScheduledMessageDto (body de PATCH /scheduled-messages/update/:id)​

CampoTipoObligatorioValidacionesDescripción
bodystringNo@IsString, @IsNotEmptyNuevo texto
scheduledAtstringNo@IsDateStringNueva fecha; se revalidan anticipación y horizonte y se reprograma el job

QueryScheduledMessageDto (query de GET /scheduled-messages/mine)​

CampoTipoObligatorioValidacionesDescripción
pagenumberNo@Transform(toInt), @IsInt, @Min(1)Página (por defecto 1)
limitnumberNo@Transform(toInt), @IsInt, @Min(1), @Max(100)Tamaño de página (por defecto 10)
statusstringNo@IsIn: pending, processing, sent, cancelled, failedFiltra por estado

Reglas de negocio al crear​

ScheduledMessageService.create rechaza la solicitud si:

  1. scheduledMessages.enabled es false en Settings (403).
  2. messageType es note y allowNotes es false (400).
  3. La fecha es inválida o ya pasó (400), o está antes de minLeadTimeMinutes o después de maxHorizonDays (400).
  4. El ticket no existe en el tenant (404).
  5. Los pendientes del ticket alcanzan maxPendingPerTicket, o los del agente alcanzan maxPendingPerAgent (400).

Después guarda el registro, encola el job y notifica por socket.

Cola, procesador y barrido​

  • Cola: scheduled-message (BullMQ). El job send lleva solo { id }, usa el _id como jobId, retraso hasta scheduledAt, 3 intentos con backoff exponencial base de 30 s, removeOnComplete y conserva los fallidos. El enqueue falla si Redis no responde en 6 s.
  • ScheduledMessageProcessor: worker que llama a ScheduledMessageSenderService.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_inactive y emite un mensaje de sistema al ticket (usa cancelledSystemMessage o un texto por defecto).
    • En otro caso crea el Message (nota o chat firmado con el nombre del autor, salvo signature = false), actualiza el último mensaje del ticket, emite por socket y enruta el envío saliente por OutboundRouterService.
  • Fallos: mientras attemptCount sea menor que 3 vuelve a pending y relanza el error para que BullMQ reintente; al tercer intento queda failed y emite el evento correspondiente.
  • ScheduledMessageReconciliationJob (@Interval, cada 60 s) ejecuta reconcile: envía los pendientes vencidos, vuelve a encolar los pendientes sin job en la cola y recupera reclamos processing con más de 5 minutos reiniciándolos a pending.
  • 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):

EventoPayload
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 id y el cuerpo se lee de MongoDB al enviar.
  • update y cancel sobre un mensaje que no está pending (o que no es del agente) responden 404 con el mensaje notPending.
  • 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.
  • mediaId está reservado: no hay adjuntos en programados.