Skip to main content

Módulo Ticket

src/app/modules/ticket/

Representa una conversación/caso de soporte entre un contacto y los agentes.

Modelo — Ticket​

Colección tickets, con timestamps. Los campos ContactId y UserId se escriben con inicial mayúscula en el esquema.

CampoTipoDescripción
_idObjectIdID de MongoDB (las rutas con :id usan este)
identifierstring (UUID)ID externo único
tenantIdObjectId → TenantTenant
ContactIdObjectId → ContactContacto asociado
UserIdObjectId → UserAgente asignado; ausente si el ticket está en cola
sendTypestringEstado del ticket (ver enum más abajo)
lastMessageObjectId → MessageÚltimo mensaje (para previews)
agentUnreadbooleanMarcado como no leído por el agente (por defecto false)
departmentIdObjectId → DepartmentDepartamento actual (por defecto null)
greetedDepartmentIdObjectId → DepartmentDepartamento cuyo saludo ya se envió
connectionIdObjectId → ConnectionConexión por la que nació; decide el dispatcher saliente
postponedUntilDateFecha en que un ticket aplazado vuelve a abrirse
resolvedAtDate | nullMomento de resolución; mide la ventana de tolerancia de reapertura
lastContactMessageAtDate | nullÚltimo mensaje del contacto; lo usa el job de inactividad
autoResolutionNotifiedAtDate | nullMarca de que ya se envió el aviso previo a la auto-resolución
outOfHoursNotifiedAtDate | nullMarca del aviso de fuera de horario
lockedByObjectId → User | nullAgente que mantiene el ticket bloqueado ("Mantener conmigo"); null si no está bloqueado
csatsubdocumentoEncuesta de satisfacción (ver abajo)
createdAt / updatedAtDateTimestamps

Índice compuesto (tenantId, ContactId, connectionId, sendType) para resolver el ticket activo de un contacto en su conexión.

Estados — sendType​

Valores de TICKET_SEND_TYPE: open, closed, inProgress, pending, postponed, resolved, cancelled. Los estados activos para asignar o bloquear son open e inProgress.

Ciclo de vida de sendType​

Transiciones verificadas en el servicio y el repositorio. El endpoint status/:id puede fijar cualquier valor del enum, por lo que no se dibuja como transición propia.

Subdocumento csat​

CampoDescripción
statusnot_applicable (por defecto), pending, awaiting_comment, answered, expired
triggeredBy, requestedAt, expiresAt, respondedAtOrigen y fechas de la solicitud y la respuesta
rating, commentCalificación y comentario
agentIdSnapshot, departmentIdSnapshotAgente y departamento al momento de la solicitud

Modelo TicketContactReason (colección ticket_contact_reasons)​

Motivo de contacto de un ticket: tenantId, ticketId, departmentId (tomado del ticket), labels (ObjectId[] → Label), description (por defecto vacío) y createdAt. Índice (ticketId, tenantId).

Endpoints​

Base: /api/v1/tickets. Controlador con @Auth() y PermissionsGuard; todo se limita al tenant del usuario. Las rutas salen de TICKET_ROUTES y CRUD_ROUTES.

MétodoRutaPermisoDescripción
GET/tickets/get-allTICKET_READLista paginada y filtrable (TicketsFilterDto)
GET/tickets/filter-agentsTICKET_FILTER_BY_AGENTAgentes disponibles para el filtro por operador
GET/tickets/get-one/:idTICKET_READDetalle de un ticket (data.results con un elemento, o vacío)
GET/tickets/:ticketId/messagesTICKET_READMensajes del ticket, paginados con page y limit
PATCH/tickets/resolve/:idTICKET_UPDATEResuelve el ticket enviando la despedida de la conexión
PATCH/tickets/resolve-no-farewell/:idTICKET_UPDATEResuelve sin despedida
PATCH/tickets/return/:idTICKET_UPDATEReabre un ticket resuelto
PATCH/tickets/unassign/:idTICKET_UPDATEDevuelve el ticket a la cola
PATCH/tickets/transfer/:idTICKET_UPDATETransfiere a un agente, a un departamento o a ambos
PATCH/tickets/postpone/:idTICKET_UPDATEAplaza el ticket hasta una fecha futura
PATCH/tickets/cancel-postpone/:idTICKET_UPDATECancela el aplazamiento
PATCH/tickets/mark-unread/:idTICKET_UPDATEMarca o desmarca como no leído
PATCH/tickets/status/:idTICKET_UPDATECambia el estado manualmente
PATCH/tickets/lock/:idTICKET_UPDATEBloquea el ticket ("Mantener conmigo")
PATCH/tickets/unlock/:idTICKET_UPDATELibera el bloqueo propio
PATCH/tickets/force-unlock/:idTICKET_UNLOCK_ANYLibera el bloqueo de cualquier agente
POST/tickets/contact-reason/:idTICKET_UPDATERegistra el motivo de contacto
DELETE/tickets/remove/:idTICKET_DELETEElimina el ticket y sus mensajes

:::warning Permisos de la vista de tickets bloqueados GET /api/v1/tickets/get-all exige TICKET_READ (@RequirePermission('TICKET_READ')), también cuando se usa lockedOnly=true. En cambio, la ruta de frontend de tickets bloqueados solo exige TICKET_UNLOCK_ANY. Un rol con TICKET_UNLOCK_ANY pero sin TICKET_READ entra a la pantalla, pero el listado responde 403. Al asignar TICKET_UNLOCK_ANY hay que conceder también TICKET_READ. Por defecto el rol ADMIN tiene ambos permisos. :::

Reglas de negocio verificadas en el servicio​

  • resolve: si procede, envía la despedida de la conexión, marca resolved con resolvedAt, cancela los mensajes programados del ticket (motivo ticket_resolved) y registra el mensaje interno "Ticket resuelto por ...".
  • return: 400 si el contacto ya tiene un ticket activo o si el ticket no es el más reciente del contacto; limpia resolvedAt.
  • unassign: solo el propietario o un usuario admin (403 en otro caso); 409 con data.code = TICKET_LOCKED si está bloqueado; 400 si no está open ni inProgress; 409 si el ticket ya cambió de dueño.
  • transfer: exige userId o departmentId (400 si faltan ambos); pueden transferir el propietario, un admin o cualquiera si el ticket está en cola; 409 TICKET_LOCKED si está bloqueado.
  • postpone: solo propietario o admin; la fecha debe ser futura (400).
  • lock: solo el agente asignado (403), con ticket open o inProgress (400); 409 si la escritura atómica falla.
  • unlock: solo quien lo bloqueó (403). force-unlock no comprueba propiedad, solo el tenant.
  • remove: difunde el cierre por socket, cancela los mensajes programados (motivo ticket_deleted), borra los mensajes del ticket y luego el ticket.

DTOs​

Validaciones de class-validator. "Opcional" indica @IsOptional().

Parámetros de ruta​

ParámetroTipoObligatorioValidaciónUsado en
idstringSíNinguna en el controlador; se convierte con new Types.ObjectId (_id de MongoDB)Todas las rutas con :id
ticketIdstringSíNingunaGET :ticketId/messages

TicketsFilterDto (query de GET /tickets/get-all)​

CampoTipoObligatorioValidacionesDescripción
pagenumberNo@Transform(toInt), @IsInt, @Min(1)Página
limitnumberNo@Transform(toInt), @IsInt, @Min(1), @Max(100)Tamaño de página
statusstringNo@IsEnum: open, closed, inProgress, pending, postponed, resolved, cancelledFiltra por sendType
assignedUserIdstringNo@IsMongoIdAgente asignado
assignedUserIdsstring[]No@Transform(toStringArray), @IsArray, @IsMongoId({ each: true }), @ArrayMaxSize(50)Varios agentes; requiere TICKET_FILTER_BY_AGENT (si no, 403) y tiene precedencia sobre assignedUserId, unassigned e includeUnassigned
unassignedbooleanNo@Transform(toBool), @IsBooleanSolo tickets sin asignar
includeUnassignedbooleanNo@Transform(toBool), @IsBooleanVista "Todos": propios más cola del departamento
contactIdstringNo@IsMongoIdContacto
createdFromstringNo@IsDateStringCreado desde
createdTostringNo@IsDateStringCreado hasta
hasUnreadbooleanNo@Transform(toBool), @IsBooleanCon no leídos
lastMessageTypestringNo@IsEnum(MESSAGE_TYPE_VALUES)Tipo del último mensaje
lastMessageSendTypestringNo@IsEnum(MESSAGE_SEND_TYPE_VALUES)Remitente del último mensaje
lastMessageAckstringNo@IsEnum(MESSAGE_ACK_VALUES)Ack del último mensaje
labelIdstringNo@IsMongoIdEtiqueta
contactIsBlockedbooleanNo@Transform(toBool), @IsBooleanContacto bloqueado
contactIsActivebooleanNo@Transform(toBool), @IsBooleanContacto activo
groupByContactbooleanNo@Transform(toBool), @IsBooleanSolo el último ticket por contacto
lockedOnlybooleanNo@Transform(toBool), @IsBooleanSolo tickets bloqueados

La respuesta incluye data.results y data.meta.

GET /tickets/:ticketId/messages​

No usa DTO: la paginación sale del decorador @Paginate() (page mínimo 1 y limit entre 1 y 100, con valores por defecto 1 y 10).

TransferTicketDto (body de PATCH /tickets/transfer/:id)​

CampoTipoObligatorioValidacionesDescripción
userIdstringCondicional@ValidateIf (solo si se envía), @IsMongoIdAgente destino
departmentIdstringCondicional@ValidateIf (solo si se envía), @IsMongoIdDepartamento destino
internalNotestringNo@IsStringNota interna; se valida pero el código actual no la lee
keepWithUserbooleanNo@IsBooleanSe valida pero el código actual no lo lee

Debe venir al menos uno de userId o departmentId; el servicio responde 400 si faltan ambos.

PostponeTicketDto (body de PATCH /tickets/postpone/:id)​

CampoTipoObligatorioValidacionesDescripción
postponedUntilstringSí@IsDateString, @IsFutureDate (validador propio: fecha estrictamente futura al momento de la petición)Fecha ISO 8601 de reapertura

MarkUnreadTicketDto (body de PATCH /tickets/mark-unread/:id)​

CampoTipoObligatorioValidacionesDescripción
agentUnreadbooleanSí@IsBooleantrue marca como no leído; false lo desmarca

ChangeStatusDto (body de PATCH /tickets/status/:id)​

CampoTipoObligatorioValidacionesDescripción
statusstringSí@IsEnum: open, closed, inProgress, pending, postponed, resolved, cancelledNuevo estado; registra el mensaje interno "Ticket ... por ..."

CreateContactReasonDto (body de POST /tickets/contact-reason/:id)​

CampoTipoObligatorioValidacionesDescripción
labelsstring[]No@IsArray, @IsMongoId({ each: true })Etiquetas del motivo
descriptionstringNo@IsStringDescripción (por defecto vacía)

Las rutas resolve, resolve-no-farewell, return, unassign, cancel-postpone, lock, unlock, force-unlock y remove no reciben body.

Jobs​

Con @Interval: reactivación de tickets aplazados vencidos (cada 30 s), resolución por inactividad (cada 60 s) y expiración de encuestas CSAT (cada 60 s).

Creación automática​

El ChatGateway crea tickets automáticamente cuando un CONTACT inicia una conversación nueva (evento AUTHENTICATE + CREATE_ROOM). No se crean manualmente.

Tests​

El módulo incluye:

  • ticket.service.spec.ts — tests unitarios del servicio
  • ticket.service.integration.spec.ts — tests de integración (usa BD real, controlado por TESTING)

Cambios en feat/meta_connections​

src/app/modules/ticket/services/ticket.service.ts

  • Todos los mensajes de sistema (saludo/despedida de conexión, saludo de departamento, aviso de fuera de horario, eventos internos de transferencia/resolución) pasan por SystemMessageService en lugar de crearse a mano.
  • getAssignableAgentIds(tenantId): agentes conectados por socket (SocketBroadcastService.getConnectedAgentIds) filtrados por filterAssignableByIds.
  • assignOnContactMessage(ticket): lo invocan el gateway y el pipeline de entrada al llegar un mensaje del contacto. Si el ticket no tiene dueño, lo autoasigna; si el dueño no está en línea (SocketBroadcastService.isUserOnline), lo redistribuye. Antes esta lógica vivía en ChatGateway.
  • resolveContactTicket(tenantId, contactId, connectionId?, messageContent?) obtiene o crea el ticket del contacto; lo usan WhatsApp y Meta.
  • Un ticket nace con connectionId; ese campo decide el dispatcher saliente (ver Message).