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.
| Campo | Tipo | Descripción |
|---|---|---|
_id | ObjectId | ID de MongoDB (las rutas con :id usan este) |
identifier | string (UUID) | ID externo único |
tenantId | ObjectId → Tenant | Tenant |
ContactId | ObjectId → Contact | Contacto asociado |
UserId | ObjectId → User | Agente asignado; ausente si el ticket está en cola |
sendType | string | Estado del ticket (ver enum más abajo) |
lastMessage | ObjectId → Message | Último mensaje (para previews) |
agentUnread | boolean | Marcado como no leído por el agente (por defecto false) |
departmentId | ObjectId → Department | Departamento actual (por defecto null) |
greetedDepartmentId | ObjectId → Department | Departamento cuyo saludo ya se envió |
connectionId | ObjectId → Connection | Conexión por la que nació; decide el dispatcher saliente |
postponedUntil | Date | Fecha en que un ticket aplazado vuelve a abrirse |
resolvedAt | Date | null | Momento de resolución; mide la ventana de tolerancia de reapertura |
lastContactMessageAt | Date | null | Último mensaje del contacto; lo usa el job de inactividad |
autoResolutionNotifiedAt | Date | null | Marca de que ya se envió el aviso previo a la auto-resolución |
outOfHoursNotifiedAt | Date | null | Marca del aviso de fuera de horario |
lockedBy | ObjectId → User | null | Agente que mantiene el ticket bloqueado ("Mantener conmigo"); null si no está bloqueado |
csat | subdocumento | Encuesta de satisfacción (ver abajo) |
createdAt / updatedAt | Date | Timestamps |
Í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
| Campo | Descripción |
|---|---|
status | not_applicable (por defecto), pending, awaiting_comment, answered, expired |
triggeredBy, requestedAt, expiresAt, respondedAt | Origen y fechas de la solicitud y la respuesta |
rating, comment | Calificación y comentario |
agentIdSnapshot, departmentIdSnapshot | Agente 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étodo | Ruta | Permiso | Descripción |
|---|---|---|---|
GET | /tickets/get-all | TICKET_READ | Lista paginada y filtrable (TicketsFilterDto) |
GET | /tickets/filter-agents | TICKET_FILTER_BY_AGENT | Agentes disponibles para el filtro por operador |
GET | /tickets/get-one/:id | TICKET_READ | Detalle de un ticket (data.results con un elemento, o vacío) |
GET | /tickets/:ticketId/messages | TICKET_READ | Mensajes del ticket, paginados con page y limit |
PATCH | /tickets/resolve/:id | TICKET_UPDATE | Resuelve el ticket enviando la despedida de la conexión |
PATCH | /tickets/resolve-no-farewell/:id | TICKET_UPDATE | Resuelve sin despedida |
PATCH | /tickets/return/:id | TICKET_UPDATE | Reabre un ticket resuelto |
PATCH | /tickets/unassign/:id | TICKET_UPDATE | Devuelve el ticket a la cola |
PATCH | /tickets/transfer/:id | TICKET_UPDATE | Transfiere a un agente, a un departamento o a ambos |
PATCH | /tickets/postpone/:id | TICKET_UPDATE | Aplaza el ticket hasta una fecha futura |
PATCH | /tickets/cancel-postpone/:id | TICKET_UPDATE | Cancela el aplazamiento |
PATCH | /tickets/mark-unread/:id | TICKET_UPDATE | Marca o desmarca como no leído |
PATCH | /tickets/status/:id | TICKET_UPDATE | Cambia el estado manualmente |
PATCH | /tickets/lock/:id | TICKET_UPDATE | Bloquea el ticket ("Mantener conmigo") |
PATCH | /tickets/unlock/:id | TICKET_UPDATE | Libera el bloqueo propio |
PATCH | /tickets/force-unlock/:id | TICKET_UNLOCK_ANY | Libera el bloqueo de cualquier agente |
POST | /tickets/contact-reason/:id | TICKET_UPDATE | Registra el motivo de contacto |
DELETE | /tickets/remove/:id | TICKET_DELETE | Elimina 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
resolvedconresolvedAt, cancela los mensajes programados del ticket (motivoticket_resolved) y registra el mensaje interno "Ticket resuelto por ...". - return:
400si el contacto ya tiene un ticket activo o si el ticket no es el más reciente del contacto; limpiaresolvedAt. - unassign: solo el propietario o un usuario
admin(403en otro caso);409condata.code = TICKET_LOCKEDsi está bloqueado;400si no estáopenniinProgress;409si el ticket ya cambió de dueño. - transfer: exige
userIdodepartmentId(400si faltan ambos); pueden transferir el propietario, unadmino cualquiera si el ticket está en cola;409TICKET_LOCKEDsi está bloqueado. - postpone: solo propietario o
admin; la fecha debe ser futura (400). - lock: solo el agente asignado (
403), con ticketopenoinProgress(400);409si 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ámetro | Tipo | Obligatorio | Validación | Usado en |
|---|---|---|---|---|
id | string | Sí | Ninguna en el controlador; se convierte con new Types.ObjectId (_id de MongoDB) | Todas las rutas con :id |
ticketId | string | Sí | Ninguna | GET :ticketId/messages |
TicketsFilterDto (query de GET /tickets/get-all)
| Campo | Tipo | Obligatorio | Validaciones | Descripción |
|---|---|---|---|---|
page | number | No | @Transform(toInt), @IsInt, @Min(1) | Página |
limit | number | No | @Transform(toInt), @IsInt, @Min(1), @Max(100) | Tamaño de página |
status | string | No | @IsEnum: open, closed, inProgress, pending, postponed, resolved, cancelled | Filtra por sendType |
assignedUserId | string | No | @IsMongoId | Agente asignado |
assignedUserIds | string[] | 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 |
unassigned | boolean | No | @Transform(toBool), @IsBoolean | Solo tickets sin asignar |
includeUnassigned | boolean | No | @Transform(toBool), @IsBoolean | Vista "Todos": propios más cola del departamento |
contactId | string | No | @IsMongoId | Contacto |
createdFrom | string | No | @IsDateString | Creado desde |
createdTo | string | No | @IsDateString | Creado hasta |
hasUnread | boolean | No | @Transform(toBool), @IsBoolean | Con no leídos |
lastMessageType | string | No | @IsEnum(MESSAGE_TYPE_VALUES) | Tipo del último mensaje |
lastMessageSendType | string | No | @IsEnum(MESSAGE_SEND_TYPE_VALUES) | Remitente del último mensaje |
lastMessageAck | string | No | @IsEnum(MESSAGE_ACK_VALUES) | Ack del último mensaje |
labelId | string | No | @IsMongoId | Etiqueta |
contactIsBlocked | boolean | No | @Transform(toBool), @IsBoolean | Contacto bloqueado |
contactIsActive | boolean | No | @Transform(toBool), @IsBoolean | Contacto activo |
groupByContact | boolean | No | @Transform(toBool), @IsBoolean | Solo el último ticket por contacto |
lockedOnly | boolean | No | @Transform(toBool), @IsBoolean | Solo 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)
| Campo | Tipo | Obligatorio | Validaciones | Descripción |
|---|---|---|---|---|
userId | string | Condicional | @ValidateIf (solo si se envía), @IsMongoId | Agente destino |
departmentId | string | Condicional | @ValidateIf (solo si se envía), @IsMongoId | Departamento destino |
internalNote | string | No | @IsString | Nota interna; se valida pero el código actual no la lee |
keepWithUser | boolean | No | @IsBoolean | Se 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)
| Campo | Tipo | Obligatorio | Validaciones | Descripción |
|---|---|---|---|---|
postponedUntil | string | Sí | @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)
| Campo | Tipo | Obligatorio | Validaciones | Descripción |
|---|---|---|---|---|
agentUnread | boolean | Sí | @IsBoolean | true marca como no leído; false lo desmarca |
ChangeStatusDto (body de PATCH /tickets/status/:id)
| Campo | Tipo | Obligatorio | Validaciones | Descripción |
|---|---|---|---|---|
status | string | Sí | @IsEnum: open, closed, inProgress, pending, postponed, resolved, cancelled | Nuevo estado; registra el mensaje interno "Ticket ... por ..." |
CreateContactReasonDto (body de POST /tickets/contact-reason/:id)
| Campo | Tipo | Obligatorio | Validaciones | Descripción |
|---|---|---|---|---|
labels | string[] | No | @IsArray, @IsMongoId({ each: true }) | Etiquetas del motivo |
description | string | No | @IsString | Descripció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 servicioticket.service.integration.spec.ts— tests de integración (usa BD real, controlado porTESTING)
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
SystemMessageServiceen lugar de crearse a mano. getAssignableAgentIds(tenantId): agentes conectados por socket (SocketBroadcastService.getConnectedAgentIds) filtrados porfilterAssignableByIds.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 enChatGateway.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).